Skip to content

Commit fb53c2d

Browse files
authored
fix(doctor): detect stale session snapshot paths (#82867)
* fix(doctor): detect stale session snapshot paths Warn when cached session snapshot metadata still references bundled skill paths from inactive OpenClaw runtime roots, while keeping workspace skill roots and current runtime paths quiet. * fix(doctor): honor configured session stores * fix(doctor): scan raw snapshot paths Expand home-relative cached snapshot paths before stale bundled-skill classification and scan raw session-store JSON so persisted resolvedSkills are inspected before normal session-store normalization strips them.
1 parent 214f718 commit fb53c2d

3 files changed

Lines changed: 727 additions & 0 deletions

File tree

Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
import type { SessionEntry } from "../config/sessions/types.js";
6+
import type { OpenClawConfig } from "../config/types.openclaw.js";
7+
8+
const note = vi.hoisted(() => vi.fn());
9+
10+
vi.mock("../terminal/note.js", () => ({
11+
note,
12+
}));
13+
14+
import {
15+
noteSessionSnapshotHealth,
16+
scanSessionStoreForStaleRuntimeSnapshotPaths,
17+
} from "./doctor-session-snapshots.js";
18+
19+
function sessionEntry(patch: Partial<SessionEntry>): SessionEntry {
20+
return {
21+
sessionId: "session-1",
22+
updatedAt: Date.now(),
23+
...patch,
24+
};
25+
}
26+
27+
function skillPrompt(location: string): string {
28+
return [
29+
"<available_skills>",
30+
" <skill>",
31+
" <name>doctor</name>",
32+
" <description>Doctor skill</description>",
33+
` <location>${location}</location>`,
34+
" </skill>",
35+
"</available_skills>",
36+
].join("\n");
37+
}
38+
39+
async function writeSessionStore(
40+
storePath: string,
41+
store: Record<string, SessionEntry>,
42+
): Promise<void> {
43+
await fs.mkdir(path.dirname(storePath), { recursive: true });
44+
await fs.writeFile(storePath, JSON.stringify(store, null, 2));
45+
}
46+
47+
describe("doctor session snapshot stale runtime metadata", () => {
48+
let root = "";
49+
let bundledSkillsDir = "";
50+
51+
beforeEach(async () => {
52+
note.mockClear();
53+
root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-doctor-session-snapshots-"));
54+
bundledSkillsDir = path.join(root, "current", "skills");
55+
await fs.mkdir(path.join(bundledSkillsDir, "doctor"), { recursive: true });
56+
await fs.writeFile(path.join(bundledSkillsDir, "doctor", "SKILL.md"), "# Doctor\n");
57+
});
58+
59+
afterEach(async () => {
60+
await fs.rm(root, { recursive: true, force: true });
61+
});
62+
63+
it("flags cached bundled skill locations from inactive and temp-backed runtime roots", () => {
64+
const stalePath = path.join(
65+
root,
66+
"old-runtime",
67+
"node_modules",
68+
"openclaw",
69+
"skills",
70+
"doctor",
71+
"SKILL.md",
72+
);
73+
const tempBackedPath = path.join(
74+
path.sep,
75+
"private",
76+
"tmp",
77+
"openclaw",
78+
"skills",
79+
"doctor",
80+
"SKILL.md",
81+
);
82+
const findings = scanSessionStoreForStaleRuntimeSnapshotPaths({
83+
bundledSkillsDir,
84+
store: {
85+
"agent:main": sessionEntry({
86+
skillsSnapshot: {
87+
prompt: skillPrompt(stalePath),
88+
skills: [{ name: "doctor" }],
89+
},
90+
}),
91+
"agent:temp": sessionEntry({
92+
skillsSnapshot: {
93+
prompt: skillPrompt(tempBackedPath),
94+
skills: [{ name: "doctor" }],
95+
},
96+
}),
97+
},
98+
});
99+
100+
expect(findings).toEqual([
101+
{
102+
sessionKey: "agent:main",
103+
field: "skillsSnapshot.prompt",
104+
cachedPath: stalePath,
105+
expectedPath: path.join(bundledSkillsDir, "doctor", "SKILL.md"),
106+
},
107+
{
108+
sessionKey: "agent:temp",
109+
field: "skillsSnapshot.prompt",
110+
cachedPath: tempBackedPath,
111+
expectedPath: path.join(bundledSkillsDir, "doctor", "SKILL.md"),
112+
},
113+
]);
114+
});
115+
116+
it("expands home-relative cached bundled skill locations before classifying them", () => {
117+
const homeDir = path.join(root, "home");
118+
const stalePath = "~/old-runtime/node_modules/openclaw/skills/doctor/SKILL.md";
119+
120+
const findings = scanSessionStoreForStaleRuntimeSnapshotPaths({
121+
bundledSkillsDir,
122+
env: { HOME: homeDir },
123+
store: {
124+
"agent:home": sessionEntry({
125+
skillsSnapshot: {
126+
prompt: skillPrompt(stalePath),
127+
skills: [{ name: "doctor" }],
128+
},
129+
}),
130+
},
131+
});
132+
133+
expect(findings).toEqual([
134+
{
135+
sessionKey: "agent:home",
136+
field: "skillsSnapshot.prompt",
137+
cachedPath: stalePath,
138+
expectedPath: path.join(bundledSkillsDir, "doctor", "SKILL.md"),
139+
},
140+
]);
141+
});
142+
143+
it("ignores current bundled locations and unrelated workspace skill locations", () => {
144+
const currentPath = path.join(bundledSkillsDir, "doctor", "SKILL.md");
145+
const workspacePath = path.join(root, "workspace", "skills", "doctor", "SKILL.md");
146+
const openClawWorkspacePath = path.join(
147+
root,
148+
"projects",
149+
"openclaw",
150+
"skills",
151+
"doctor",
152+
"SKILL.md",
153+
);
154+
const findings = scanSessionStoreForStaleRuntimeSnapshotPaths({
155+
bundledSkillsDir,
156+
store: {
157+
"agent:current": sessionEntry({
158+
skillsSnapshot: { prompt: skillPrompt(currentPath), skills: [{ name: "doctor" }] },
159+
}),
160+
"agent:workspace": sessionEntry({
161+
skillsSnapshot: { prompt: skillPrompt(workspacePath), skills: [{ name: "doctor" }] },
162+
}),
163+
"agent:openclaw-workspace": sessionEntry({
164+
skillsSnapshot: {
165+
prompt: skillPrompt(openClawWorkspacePath),
166+
skills: [{ name: "doctor" }],
167+
},
168+
}),
169+
},
170+
pathExists: (filePath) => filePath === currentPath,
171+
});
172+
173+
expect(findings).toEqual([]);
174+
});
175+
176+
it("handles Windows current and stale bundled skill paths without false positives", () => {
177+
const windowsBundledSkillsDir = path.win32.join(
178+
"C:\\",
179+
"Users",
180+
"alice",
181+
".openclaw",
182+
"lib",
183+
"node_modules",
184+
"openclaw",
185+
"skills",
186+
);
187+
const currentPath = path.win32.join(windowsBundledSkillsDir, "doctor", "SKILL.md");
188+
const stalePath = path.win32.join(
189+
"C:\\",
190+
"opt",
191+
"node_modules",
192+
"openclaw",
193+
"skills",
194+
"doctor",
195+
"SKILL.md",
196+
);
197+
198+
const findings = scanSessionStoreForStaleRuntimeSnapshotPaths({
199+
bundledSkillsDir: windowsBundledSkillsDir,
200+
store: {
201+
"agent:current": sessionEntry({
202+
skillsSnapshot: { prompt: skillPrompt(currentPath), skills: [{ name: "doctor" }] },
203+
}),
204+
"agent:stale": sessionEntry({
205+
skillsSnapshot: { prompt: skillPrompt(stalePath), skills: [{ name: "doctor" }] },
206+
}),
207+
},
208+
pathExists: (filePath) => filePath === currentPath,
209+
});
210+
211+
expect(findings).toEqual([
212+
{
213+
sessionKey: "agent:stale",
214+
field: "skillsSnapshot.prompt",
215+
cachedPath: stalePath,
216+
expectedPath: currentPath,
217+
},
218+
]);
219+
});
220+
221+
it("reports stale cached metadata while distinguishing the live runtime root", async () => {
222+
const stalePath = path.join(
223+
root,
224+
"old-runtime",
225+
"node_modules",
226+
"openclaw",
227+
"skills",
228+
"doctor",
229+
"SKILL.md",
230+
);
231+
const storePath = path.join(root, "state", "agents", "main", "sessions", "sessions.json");
232+
await writeSessionStore(storePath, {
233+
"agent:main": sessionEntry({
234+
skillsSnapshot: {
235+
prompt: skillPrompt(stalePath),
236+
skills: [{ name: "doctor" }],
237+
},
238+
}),
239+
});
240+
241+
await noteSessionSnapshotHealth({ storePaths: [storePath], bundledSkillsDir });
242+
243+
expect(note).toHaveBeenCalledTimes(1);
244+
const [message, title] = note.mock.calls[0] as [string, string];
245+
expect(title).toBe("Session snapshots");
246+
expect(message).toContain("stale cached session metadata paths");
247+
expect(message).toContain("Live bundled skills root is healthy");
248+
expect(message).toContain("inactive runtime root");
249+
expect(message).toContain(stalePath);
250+
expect(message).toContain(path.join(bundledSkillsDir, "doctor", "SKILL.md"));
251+
});
252+
253+
it("scans resolvedSkills before session store normalization strips them", async () => {
254+
const stalePath = path.join(
255+
root,
256+
"old-runtime",
257+
"node_modules",
258+
"openclaw",
259+
"skills",
260+
"doctor",
261+
"SKILL.md",
262+
);
263+
const storePath = path.join(root, "state", "agents", "main", "sessions", "sessions.json");
264+
await writeSessionStore(storePath, {
265+
"agent:main": sessionEntry({
266+
skillsSnapshot: {
267+
prompt: "",
268+
skills: [{ name: "doctor" }],
269+
resolvedSkills: [
270+
{
271+
name: "doctor",
272+
description: "Doctor skill",
273+
source: "bundled",
274+
filePath: stalePath,
275+
baseDir: path.dirname(stalePath),
276+
sourceInfo: {
277+
path: stalePath,
278+
source: "bundled",
279+
scope: "user",
280+
origin: "top-level",
281+
baseDir: path.dirname(stalePath),
282+
},
283+
disableModelInvocation: false,
284+
},
285+
],
286+
},
287+
}),
288+
});
289+
290+
await noteSessionSnapshotHealth({ storePaths: [storePath], bundledSkillsDir });
291+
292+
expect(note).toHaveBeenCalledTimes(1);
293+
const [message] = note.mock.calls[0] as [string, string];
294+
expect(message).toContain("agent:main");
295+
expect(message).toContain("skillsSnapshot.resolvedSkills");
296+
expect(message).toContain(stalePath);
297+
});
298+
299+
it("reports stale cached metadata from configured session stores", async () => {
300+
const stalePath = path.join(
301+
root,
302+
"old-runtime",
303+
"node_modules",
304+
"openclaw",
305+
"skills",
306+
"doctor",
307+
"SKILL.md",
308+
);
309+
const stateDir = path.join(root, "state");
310+
const defaultStorePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
311+
const configuredStorePath = path.join(root, "configured-sessions.json");
312+
await writeSessionStore(defaultStorePath, {});
313+
await writeSessionStore(configuredStorePath, {
314+
"agent:configured": sessionEntry({
315+
skillsSnapshot: {
316+
prompt: skillPrompt(stalePath),
317+
skills: [{ name: "doctor" }],
318+
},
319+
}),
320+
});
321+
322+
await noteSessionSnapshotHealth({
323+
cfg: { session: { store: configuredStorePath } } as OpenClawConfig,
324+
bundledSkillsDir,
325+
env: { OPENCLAW_STATE_DIR: stateDir },
326+
});
327+
328+
expect(note).toHaveBeenCalledTimes(1);
329+
const [message] = note.mock.calls[0] as [string, string];
330+
expect(message).toContain(configuredStorePath);
331+
expect(message).toContain("agent:configured");
332+
expect(message).toContain(stalePath);
333+
});
334+
335+
it("reports stale cached metadata from templated configured session stores", async () => {
336+
const stalePath = path.join(
337+
root,
338+
"old-runtime",
339+
"node_modules",
340+
"openclaw",
341+
"skills",
342+
"doctor",
343+
"SKILL.md",
344+
);
345+
const templatedStore = path.join(root, "stores", "{agentId}", "sessions.json");
346+
const opsStorePath = path.join(root, "stores", "ops", "sessions.json");
347+
await writeSessionStore(opsStorePath, {
348+
"agent:ops": sessionEntry({
349+
skillsSnapshot: {
350+
prompt: skillPrompt(stalePath),
351+
skills: [{ name: "doctor" }],
352+
},
353+
}),
354+
});
355+
356+
await noteSessionSnapshotHealth({
357+
cfg: {
358+
session: { store: templatedStore },
359+
agents: { list: [{ id: "main" }, { id: "ops" }] },
360+
} as OpenClawConfig,
361+
bundledSkillsDir,
362+
env: { OPENCLAW_STATE_DIR: path.join(root, "state") },
363+
});
364+
365+
expect(note).toHaveBeenCalledTimes(1);
366+
const [message] = note.mock.calls[0] as [string, string];
367+
expect(message).toContain(opsStorePath);
368+
expect(message).toContain("agent:ops");
369+
expect(message).toContain(stalePath);
370+
});
371+
});

0 commit comments

Comments
 (0)