diff --git a/CHANGELOG.md b/CHANGELOG.md index bc95bbf9cec..47b64f8f863 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ Docs: https://docs.openclaw.ai ### Fixes - Process/Windows: decode command stdout and stderr from raw bytes with console-codepage awareness, while preserving valid UTF-8 output and multibyte characters split across chunks. Fixes #50519. Thanks @iready, @kevinten10, @zhangyongjie1997, @knightplat-blip, @heiqishi666, and @slepybear. +- Agents/bootstrap: dedupe hook-injected bootstrap context files by workspace-relative path and store normalized resolved paths so duplicate relative and absolute hook paths no longer depend on the process cwd. (#59344; fixes #59319; related #56721, #56725, and #57587) Thanks @koen666. +- Agents/bootstrap: refresh cached workspace bootstrap snapshots on long-lived main-session turns when `AGENTS.md`, `SOUL.md`, `MEMORY.md`, or `TOOLS.md` change on disk, while preserving unchanged snapshot identity through the workspace file cache. (#64871; related #43901, #26497, #28594, #30896) Thanks @aimqwest and @mikejuyoon. - macOS Gateway: detect installed-but-unloaded LaunchAgent split-brain states during status, doctor, and restart, and re-bootstrap launchd supervision before falling back to unmanaged listener restarts. Fixes #67335, #53475, and #71060; refs #58890, #60885, and #70801. Thanks @ze1tgeist88, @dafacto, and @vishutdhar. - Plugins/install: stage bundled plugin runtime dependencies before Gateway startup, drain update restarts, and materialize plugin-owned root chunks in external mirrors so staged deps resolve under native ESM. Fixes #72058; supersedes #72084. Thanks @amnesia106 and @drvoss. - TTS/SecretRef: resolve `messages.tts.providers.*.apiKey` from the active runtime snapshot so SecretRef-backed MiniMax and other TTS provider keys work in runtime reply/audio paths. Fixes #68690. Thanks @joshavant. diff --git a/src/agents/bootstrap-cache.test.ts b/src/agents/bootstrap-cache.test.ts index 2509d6a5277..c728443cbda 100644 --- a/src/agents/bootstrap-cache.test.ts +++ b/src/agents/bootstrap-cache.test.ts @@ -48,12 +48,29 @@ describe("getOrLoadBootstrapFiles", () => { expect(mockLoad()).toHaveBeenCalledTimes(1); }); - it("returns cached result on second call", async () => { - await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "session-1" }); + it("refreshes from disk on second call while preserving unchanged object identity", async () => { + const refreshedFiles = [makeFile("AGENTS.md", "# Agent"), makeFile("SOUL.md", "# Soul")]; + mockLoad().mockResolvedValueOnce(files).mockResolvedValueOnce(refreshedFiles); + + const first = await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "session-1" }); const result = await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "session-1" }); - expect(result).toBe(files); - expect(mockLoad()).toHaveBeenCalledTimes(1); + expect(first).toBe(files); + expect(result).toBe(first); + expect(result).not.toBe(refreshedFiles); + expect(mockLoad()).toHaveBeenCalledTimes(2); + }); + + it("replaces cached result when workspace bootstrap contents change", async () => { + const updatedFiles = [makeFile("AGENTS.md", "# Agent v2"), makeFile("SOUL.md", "# Soul")]; + mockLoad().mockResolvedValueOnce(files).mockResolvedValueOnce(updatedFiles); + + const first = await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "session-1" }); + const result = await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "session-1" }); + + expect(first).toBe(files); + expect(result).toBe(updatedFiles); + expect(mockLoad()).toHaveBeenCalledTimes(2); }); it("different session keys get independent caches", async () => { @@ -104,12 +121,13 @@ describe("clearBootstrapSnapshot", () => { it("does not affect other sessions", async () => { await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "sk1" }); - await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "sk2" }); + const first = await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "sk2" }); clearBootstrapSnapshot("sk1"); - // sk2 should still be cached. - await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "sk2" }); - expect(mockLoad()).toHaveBeenCalledTimes(2); // sk1 x1, sk2 x1 + // sk2 should still preserve its cached snapshot identity after refresh. + const second = await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "sk2" }); + expect(second).toBe(first); + expect(mockLoad()).toHaveBeenCalledTimes(3); // sk1 x1, sk2 x2 }); }); diff --git a/src/agents/bootstrap-cache.ts b/src/agents/bootstrap-cache.ts index 98ca267994f..7d3fd86aaed 100644 --- a/src/agents/bootstrap-cache.ts +++ b/src/agents/bootstrap-cache.ts @@ -1,18 +1,49 @@ import { loadWorkspaceBootstrapFiles, type WorkspaceBootstrapFile } from "./workspace.js"; -const cache = new Map(); +type BootstrapSnapshot = { + workspaceDir: string; + files: WorkspaceBootstrapFile[]; +}; + +const cache = new Map(); + +function bootstrapFilesEqual( + previous: WorkspaceBootstrapFile[], + next: WorkspaceBootstrapFile[], +): boolean { + if (previous.length !== next.length) { + return false; + } + + return previous.every((file, index) => { + const updated = next[index]; + return ( + updated !== undefined && + file.name === updated.name && + file.path === updated.path && + file.content === updated.content && + file.missing === updated.missing + ); + }); +} export async function getOrLoadBootstrapFiles(params: { workspaceDir: string; sessionKey: string; }): Promise { const existing = cache.get(params.sessionKey); - if (existing) { - return existing; + // Refresh per turn so long-lived sessions pick up edits; loadWorkspaceBootstrapFiles + // handles unchanged file content through its guarded inode/mtime cache. + const files = await loadWorkspaceBootstrapFiles(params.workspaceDir); + if ( + existing && + existing.workspaceDir === params.workspaceDir && + bootstrapFilesEqual(existing.files, files) + ) { + return existing.files; } - const files = await loadWorkspaceBootstrapFiles(params.workspaceDir); - cache.set(params.sessionKey, files); + cache.set(params.sessionKey, { workspaceDir: params.workspaceDir, files }); return files; } diff --git a/src/agents/bootstrap-files.test.ts b/src/agents/bootstrap-files.test.ts index d2891af7feb..3ea591cac30 100644 --- a/src/agents/bootstrap-files.test.ts +++ b/src/agents/bootstrap-files.test.ts @@ -60,6 +60,27 @@ function registerMalformedBootstrapFileHook() { }); } +function registerDuplicateBootstrapFileHook() { + registerInternalHook("agent:bootstrap", (event) => { + const context = event.context as AgentBootstrapHookContext; + context.bootstrapFiles = [ + ...context.bootstrapFiles, + { + name: "AGENTS.md", + path: "AGENTS.md", + content: "duplicate relative hook content", + missing: false, + }, + { + name: "AGENTS.md", + path: path.join(context.workspaceDir, ".", "AGENTS.md"), + content: "duplicate absolute hook content", + missing: false, + }, + ]; + }); +} + async function createHeartbeatAgentsWorkspace() { const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); await fs.writeFile(path.join(workspaceDir, "HEARTBEAT.md"), "check inbox", "utf8"); @@ -101,6 +122,25 @@ describe("resolveBootstrapFilesForRun", () => { expect(warnings).toHaveLength(3); expect(warnings[0]).toContain('missing or invalid "path" field'); }); + + it("dedupes hook-injected bootstrap paths relative to the workspace", async () => { + registerDuplicateBootstrapFileHook(); + + const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); + const agentsPath = path.join(workspaceDir, "AGENTS.md"); + await fs.writeFile(agentsPath, "workspace rules", "utf8"); + + const files = await resolveBootstrapFilesForRun({ workspaceDir }); + const agentsFiles = files.filter((file) => file.path === agentsPath); + + expect(agentsFiles).toHaveLength(1); + expect(agentsFiles[0]?.content).toBe("workspace rules"); + + const context = await resolveBootstrapContextForRun({ workspaceDir }); + const agentsContextFiles = context.contextFiles.filter((file) => file.path === agentsPath); + expect(agentsContextFiles).toHaveLength(1); + expect(agentsContextFiles[0]?.content).toBe("workspace rules"); + }); }); describe("resolveBootstrapContextForRun", () => { diff --git a/src/agents/bootstrap-files.ts b/src/agents/bootstrap-files.ts index c4957881068..5832273b4ad 100644 --- a/src/agents/bootstrap-files.ts +++ b/src/agents/bootstrap-files.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import path from "node:path"; import type { AgentContextInjection } from "../config/types.agent-defaults.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; @@ -146,8 +147,11 @@ export function makeBootstrapWarn(params: { function sanitizeBootstrapFiles( files: WorkspaceBootstrapFile[], + workspaceDir: string, warn?: (message: string) => void, ): WorkspaceBootstrapFile[] { + const workspaceRoot = path.resolve(workspaceDir); + const seenPaths = new Set(); const sanitized: WorkspaceBootstrapFile[] = []; for (const file of files) { const pathValue = normalizeOptionalString(file.path) ?? ""; @@ -157,7 +161,15 @@ function sanitizeBootstrapFiles( ); continue; } - sanitized.push({ ...file, path: pathValue }); + const resolvedPath = path.isAbsolute(pathValue) + ? path.resolve(pathValue) + : path.resolve(workspaceRoot, pathValue); + const dedupeKey = path.normalize(path.relative(workspaceRoot, resolvedPath)); + if (seenPaths.has(dedupeKey)) { + continue; + } + seenPaths.add(dedupeKey); + sanitized.push({ ...file, path: resolvedPath }); } return sanitized; } @@ -248,6 +260,7 @@ export async function resolveBootstrapFilesForRun(params: { }); return sanitizeBootstrapFiles( filterHeartbeatBootstrapFile(updated, excludeHeartbeatBootstrapFile), + params.workspaceDir, params.warn, ); } diff --git a/src/agents/workspace.bootstrap-cache.test.ts b/src/agents/workspace.bootstrap-cache.test.ts index 6d5300feba1..84376a6b6d8 100644 --- a/src/agents/workspace.bootstrap-cache.test.ts +++ b/src/agents/workspace.bootstrap-cache.test.ts @@ -1,21 +1,32 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { describe, expect, it, beforeEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js"; +import { clearAllBootstrapSnapshots, getOrLoadBootstrapFiles } from "./bootstrap-cache.js"; import { loadWorkspaceBootstrapFiles, DEFAULT_AGENTS_FILENAME } from "./workspace.js"; describe("workspace bootstrap file caching", () => { let workspaceDir: string; beforeEach(async () => { + clearAllBootstrapSnapshots(); workspaceDir = await makeTempWorkspace("openclaw-bootstrap-cache-test-"); }); + afterEach(() => { + clearAllBootstrapSnapshots(); + }); + const loadAgentsFile = async (dir: string) => { const result = await loadWorkspaceBootstrapFiles(dir); return result.find((f) => f.name === DEFAULT_AGENTS_FILENAME); }; + const loadSessionAgentsFile = async (dir: string, sessionKey: string) => { + const result = await getOrLoadBootstrapFiles({ workspaceDir: dir, sessionKey }); + return result.find((f) => f.name === DEFAULT_AGENTS_FILENAME); + }; + const expectAgentsContent = ( agentsFile: Awaited>, content: string, @@ -74,6 +85,32 @@ describe("workspace bootstrap file caching", () => { expectAgentsContent(agentsFile2, content2); }); + it("refreshes session bootstrap snapshots after workspace file changes", async () => { + const content1 = "# Initial content"; + const content2 = "# Updated content"; + const filePath = path.join(workspaceDir, DEFAULT_AGENTS_FILENAME); + + await writeWorkspaceFile({ + dir: workspaceDir, + name: DEFAULT_AGENTS_FILENAME, + content: content1, + }); + + const agentsFile1 = await loadSessionAgentsFile(workspaceDir, "agent:main:main"); + expectAgentsContent(agentsFile1, content1); + + await writeWorkspaceFile({ + dir: workspaceDir, + name: DEFAULT_AGENTS_FILENAME, + content: content2, + }); + const bumpedTime = new Date(Date.now() + 1_000); + await fs.utimes(filePath, bumpedTime, bumpedTime); + + const agentsFile2 = await loadSessionAgentsFile(workspaceDir, "agent:main:main"); + expectAgentsContent(agentsFile2, content2); + }); + it("invalidates cache when inode changes with same mtime", async () => { if (process.platform === "win32") { return;