fix(agents): refresh bootstrap snapshot when workspace files change (#72406)

* fix(agents): refresh bootstrap snapshot when workspace files change

* fix(clownfish): address review for ghcrawl-207042-agentic-merge (1)
This commit is contained in:
Vincent Koc
2026-04-26 23:39:33 -07:00
committed by GitHub
parent c110f8c028
commit 015f7dc747
6 changed files with 156 additions and 15 deletions

View File

@@ -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.

View File

@@ -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
});
});

View File

@@ -1,18 +1,49 @@
import { loadWorkspaceBootstrapFiles, type WorkspaceBootstrapFile } from "./workspace.js";
const cache = new Map<string, WorkspaceBootstrapFile[]>();
type BootstrapSnapshot = {
workspaceDir: string;
files: WorkspaceBootstrapFile[];
};
const cache = new Map<string, BootstrapSnapshot>();
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<WorkspaceBootstrapFile[]> {
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;
}

View File

@@ -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", () => {

View File

@@ -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<string>();
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,
);
}

View File

@@ -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<ReturnType<typeof loadAgentsFile>>,
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;