mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:50:43 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user