Skip to content

Commit 015f7dc

Browse files
authored
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)
1 parent c110f8c commit 015f7dc

6 files changed

Lines changed: 156 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ Docs: https://docs.openclaw.ai
1313
### Fixes
1414

1515
- 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.
16+
- 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.
17+
- 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.
1618
- 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.
1719
- 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.
1820
- 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.

src/agents/bootstrap-cache.test.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,29 @@ describe("getOrLoadBootstrapFiles", () => {
4848
expect(mockLoad()).toHaveBeenCalledTimes(1);
4949
});
5050

51-
it("returns cached result on second call", async () => {
52-
await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "session-1" });
51+
it("refreshes from disk on second call while preserving unchanged object identity", async () => {
52+
const refreshedFiles = [makeFile("AGENTS.md", "# Agent"), makeFile("SOUL.md", "# Soul")];
53+
mockLoad().mockResolvedValueOnce(files).mockResolvedValueOnce(refreshedFiles);
54+
55+
const first = await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "session-1" });
5356
const result = await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "session-1" });
5457

55-
expect(result).toBe(files);
56-
expect(mockLoad()).toHaveBeenCalledTimes(1);
58+
expect(first).toBe(files);
59+
expect(result).toBe(first);
60+
expect(result).not.toBe(refreshedFiles);
61+
expect(mockLoad()).toHaveBeenCalledTimes(2);
62+
});
63+
64+
it("replaces cached result when workspace bootstrap contents change", async () => {
65+
const updatedFiles = [makeFile("AGENTS.md", "# Agent v2"), makeFile("SOUL.md", "# Soul")];
66+
mockLoad().mockResolvedValueOnce(files).mockResolvedValueOnce(updatedFiles);
67+
68+
const first = await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "session-1" });
69+
const result = await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "session-1" });
70+
71+
expect(first).toBe(files);
72+
expect(result).toBe(updatedFiles);
73+
expect(mockLoad()).toHaveBeenCalledTimes(2);
5774
});
5875

5976
it("different session keys get independent caches", async () => {
@@ -104,12 +121,13 @@ describe("clearBootstrapSnapshot", () => {
104121

105122
it("does not affect other sessions", async () => {
106123
await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "sk1" });
107-
await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "sk2" });
124+
const first = await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "sk2" });
108125

109126
clearBootstrapSnapshot("sk1");
110127

111-
// sk2 should still be cached.
112-
await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "sk2" });
113-
expect(mockLoad()).toHaveBeenCalledTimes(2); // sk1 x1, sk2 x1
128+
// sk2 should still preserve its cached snapshot identity after refresh.
129+
const second = await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "sk2" });
130+
expect(second).toBe(first);
131+
expect(mockLoad()).toHaveBeenCalledTimes(3); // sk1 x1, sk2 x2
114132
});
115133
});

src/agents/bootstrap-cache.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,49 @@
11
import { loadWorkspaceBootstrapFiles, type WorkspaceBootstrapFile } from "./workspace.js";
22

3-
const cache = new Map<string, WorkspaceBootstrapFile[]>();
3+
type BootstrapSnapshot = {
4+
workspaceDir: string;
5+
files: WorkspaceBootstrapFile[];
6+
};
7+
8+
const cache = new Map<string, BootstrapSnapshot>();
9+
10+
function bootstrapFilesEqual(
11+
previous: WorkspaceBootstrapFile[],
12+
next: WorkspaceBootstrapFile[],
13+
): boolean {
14+
if (previous.length !== next.length) {
15+
return false;
16+
}
17+
18+
return previous.every((file, index) => {
19+
const updated = next[index];
20+
return (
21+
updated !== undefined &&
22+
file.name === updated.name &&
23+
file.path === updated.path &&
24+
file.content === updated.content &&
25+
file.missing === updated.missing
26+
);
27+
});
28+
}
429

530
export async function getOrLoadBootstrapFiles(params: {
631
workspaceDir: string;
732
sessionKey: string;
833
}): Promise<WorkspaceBootstrapFile[]> {
934
const existing = cache.get(params.sessionKey);
10-
if (existing) {
11-
return existing;
35+
// Refresh per turn so long-lived sessions pick up edits; loadWorkspaceBootstrapFiles
36+
// handles unchanged file content through its guarded inode/mtime cache.
37+
const files = await loadWorkspaceBootstrapFiles(params.workspaceDir);
38+
if (
39+
existing &&
40+
existing.workspaceDir === params.workspaceDir &&
41+
bootstrapFilesEqual(existing.files, files)
42+
) {
43+
return existing.files;
1244
}
1345

14-
const files = await loadWorkspaceBootstrapFiles(params.workspaceDir);
15-
cache.set(params.sessionKey, files);
46+
cache.set(params.sessionKey, { workspaceDir: params.workspaceDir, files });
1647
return files;
1748
}
1849

src/agents/bootstrap-files.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,27 @@ function registerMalformedBootstrapFileHook() {
6060
});
6161
}
6262

63+
function registerDuplicateBootstrapFileHook() {
64+
registerInternalHook("agent:bootstrap", (event) => {
65+
const context = event.context as AgentBootstrapHookContext;
66+
context.bootstrapFiles = [
67+
...context.bootstrapFiles,
68+
{
69+
name: "AGENTS.md",
70+
path: "AGENTS.md",
71+
content: "duplicate relative hook content",
72+
missing: false,
73+
},
74+
{
75+
name: "AGENTS.md",
76+
path: path.join(context.workspaceDir, ".", "AGENTS.md"),
77+
content: "duplicate absolute hook content",
78+
missing: false,
79+
},
80+
];
81+
});
82+
}
83+
6384
async function createHeartbeatAgentsWorkspace() {
6485
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
6586
await fs.writeFile(path.join(workspaceDir, "HEARTBEAT.md"), "check inbox", "utf8");
@@ -101,6 +122,25 @@ describe("resolveBootstrapFilesForRun", () => {
101122
expect(warnings).toHaveLength(3);
102123
expect(warnings[0]).toContain('missing or invalid "path" field');
103124
});
125+
126+
it("dedupes hook-injected bootstrap paths relative to the workspace", async () => {
127+
registerDuplicateBootstrapFileHook();
128+
129+
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
130+
const agentsPath = path.join(workspaceDir, "AGENTS.md");
131+
await fs.writeFile(agentsPath, "workspace rules", "utf8");
132+
133+
const files = await resolveBootstrapFilesForRun({ workspaceDir });
134+
const agentsFiles = files.filter((file) => file.path === agentsPath);
135+
136+
expect(agentsFiles).toHaveLength(1);
137+
expect(agentsFiles[0]?.content).toBe("workspace rules");
138+
139+
const context = await resolveBootstrapContextForRun({ workspaceDir });
140+
const agentsContextFiles = context.contextFiles.filter((file) => file.path === agentsPath);
141+
expect(agentsContextFiles).toHaveLength(1);
142+
expect(agentsContextFiles[0]?.content).toBe("workspace rules");
143+
});
104144
});
105145

106146
describe("resolveBootstrapContextForRun", () => {

src/agents/bootstrap-files.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs from "node:fs/promises";
2+
import path from "node:path";
23
import type { AgentContextInjection } from "../config/types.agent-defaults.js";
34
import type { OpenClawConfig } from "../config/types.openclaw.js";
45
import { normalizeOptionalString } from "../shared/string-coerce.js";
@@ -146,8 +147,11 @@ export function makeBootstrapWarn(params: {
146147

147148
function sanitizeBootstrapFiles(
148149
files: WorkspaceBootstrapFile[],
150+
workspaceDir: string,
149151
warn?: (message: string) => void,
150152
): WorkspaceBootstrapFile[] {
153+
const workspaceRoot = path.resolve(workspaceDir);
154+
const seenPaths = new Set<string>();
151155
const sanitized: WorkspaceBootstrapFile[] = [];
152156
for (const file of files) {
153157
const pathValue = normalizeOptionalString(file.path) ?? "";
@@ -157,7 +161,15 @@ function sanitizeBootstrapFiles(
157161
);
158162
continue;
159163
}
160-
sanitized.push({ ...file, path: pathValue });
164+
const resolvedPath = path.isAbsolute(pathValue)
165+
? path.resolve(pathValue)
166+
: path.resolve(workspaceRoot, pathValue);
167+
const dedupeKey = path.normalize(path.relative(workspaceRoot, resolvedPath));
168+
if (seenPaths.has(dedupeKey)) {
169+
continue;
170+
}
171+
seenPaths.add(dedupeKey);
172+
sanitized.push({ ...file, path: resolvedPath });
161173
}
162174
return sanitized;
163175
}
@@ -248,6 +260,7 @@ export async function resolveBootstrapFilesForRun(params: {
248260
});
249261
return sanitizeBootstrapFiles(
250262
filterHeartbeatBootstrapFile(updated, excludeHeartbeatBootstrapFile),
263+
params.workspaceDir,
251264
params.warn,
252265
);
253266
}

src/agents/workspace.bootstrap-cache.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,32 @@
11
import fs from "node:fs/promises";
22
import path from "node:path";
3-
import { describe, expect, it, beforeEach } from "vitest";
3+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
44
import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js";
5+
import { clearAllBootstrapSnapshots, getOrLoadBootstrapFiles } from "./bootstrap-cache.js";
56
import { loadWorkspaceBootstrapFiles, DEFAULT_AGENTS_FILENAME } from "./workspace.js";
67

78
describe("workspace bootstrap file caching", () => {
89
let workspaceDir: string;
910

1011
beforeEach(async () => {
12+
clearAllBootstrapSnapshots();
1113
workspaceDir = await makeTempWorkspace("openclaw-bootstrap-cache-test-");
1214
});
1315

16+
afterEach(() => {
17+
clearAllBootstrapSnapshots();
18+
});
19+
1420
const loadAgentsFile = async (dir: string) => {
1521
const result = await loadWorkspaceBootstrapFiles(dir);
1622
return result.find((f) => f.name === DEFAULT_AGENTS_FILENAME);
1723
};
1824

25+
const loadSessionAgentsFile = async (dir: string, sessionKey: string) => {
26+
const result = await getOrLoadBootstrapFiles({ workspaceDir: dir, sessionKey });
27+
return result.find((f) => f.name === DEFAULT_AGENTS_FILENAME);
28+
};
29+
1930
const expectAgentsContent = (
2031
agentsFile: Awaited<ReturnType<typeof loadAgentsFile>>,
2132
content: string,
@@ -74,6 +85,32 @@ describe("workspace bootstrap file caching", () => {
7485
expectAgentsContent(agentsFile2, content2);
7586
});
7687

88+
it("refreshes session bootstrap snapshots after workspace file changes", async () => {
89+
const content1 = "# Initial content";
90+
const content2 = "# Updated content";
91+
const filePath = path.join(workspaceDir, DEFAULT_AGENTS_FILENAME);
92+
93+
await writeWorkspaceFile({
94+
dir: workspaceDir,
95+
name: DEFAULT_AGENTS_FILENAME,
96+
content: content1,
97+
});
98+
99+
const agentsFile1 = await loadSessionAgentsFile(workspaceDir, "agent:main:main");
100+
expectAgentsContent(agentsFile1, content1);
101+
102+
await writeWorkspaceFile({
103+
dir: workspaceDir,
104+
name: DEFAULT_AGENTS_FILENAME,
105+
content: content2,
106+
});
107+
const bumpedTime = new Date(Date.now() + 1_000);
108+
await fs.utimes(filePath, bumpedTime, bumpedTime);
109+
110+
const agentsFile2 = await loadSessionAgentsFile(workspaceDir, "agent:main:main");
111+
expectAgentsContent(agentsFile2, content2);
112+
});
113+
77114
it("invalidates cache when inode changes with same mtime", async () => {
78115
if (process.platform === "win32") {
79116
return;

0 commit comments

Comments
 (0)