Skip to content

Commit 1a242cd

Browse files
clawsweeper[bot]TurboTheTurtleTakhoffman
authored
fix(memory-wiki): preserve fs-safe write diagnostics (#83839)
Summary: - The branch narrows Memory Wiki imported-source `FsSafeError` wrapping, adds directory-collision bridge regressions, and adds a changelog entry crediting the source PR. - Reproducibility: yes. Source inspection shows current main catches all imported-source `FsSafeError`s with symlink wording, and the linked source PR includes live bridge-sync output for the directory-collision path. Automerge notes: - PR branch already contained follow-up commit before automerge: fix(memory-wiki): normalize source page stat guard - PR branch already contained follow-up commit before automerge: fix(memory-wiki): preserve fs-safe write diagnostics Validation: - ClawSweeper review passed for head e38ae3b. - Required merge gates passed before the squash merge. Prepared head SHA: e38ae3b Review: #83839 (comment) Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: takhoffman Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
1 parent 2c8f78e commit 1a242cd

3 files changed

Lines changed: 99 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ Docs: https://docs.openclaw.ai
5151
### Fixes
5252

5353
- Memory/search: scan the JS-side fallback vector path (used when the sqlite-vec index is unavailable or has a mismatched dimension) in bounded rowid batches and yield to the event loop between batches so large chunk tables can no longer pin the Node.js main thread for multi-second windows. Also keeps the SQL prepared statement rooted in a local so node:sqlite cannot finalize it mid-scan under heap pressure. Fixes #81172. Thanks @dev23xyz-oss.
54+
- Memory Wiki: preserve fs-safe diagnostics when bridge source page writes fail for non-symlink filesystem safety reasons, so directory collisions are reported with the underlying error code. (#83776) Thanks @TurboTheTurtle.
5455
- CLI/update: bypass npm freshness filters consistently during managed package and plugin installs so freshly published release plugins remain installable. Thanks @jalehman.
5556
- CLI/update: guide root-owned npm install EACCES recovery by stopping the managed Gateway before manual package replacement, then reinstalling and restarting the service. Fixes #83747. (#83757) Thanks @brokemac79.
5657
- Agents/subagents: keep collect-mode announce queues batching unresolved-origin items with compatible same-route messages and resume collection after a true cross-channel drain when a later compatible batch remains. Fixes #83577.

extensions/memory-wiki/src/bridge.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,80 @@ describe("syncMemoryWikiBridgeSources", () => {
346346
await expect(fs.readFile(externalTarget, "utf8")).resolves.toBe("external target\n");
347347
});
348348

349+
async function createDirectoryCollisionFixture(params: {
350+
workspaceName: string;
351+
vaultName: string;
352+
populateDirectory?: boolean;
353+
}) {
354+
const workspaceDir = await createBridgeWorkspace(params.workspaceName);
355+
const { rootDir: vaultDir, config } = await createVault({
356+
rootDir: nextCaseRoot(params.vaultName),
357+
config: {
358+
vaultMode: "bridge",
359+
bridge: {
360+
enabled: true,
361+
readMemoryArtifacts: true,
362+
indexMemoryRoot: true,
363+
},
364+
},
365+
});
366+
const memoryPath = path.join(workspaceDir, "MEMORY.md");
367+
await fs.writeFile(memoryPath, "# Durable Memory\n", "utf8");
368+
registerBridgeArtifacts([
369+
{
370+
kind: "memory-root",
371+
workspaceDir,
372+
relativePath: "MEMORY.md",
373+
absolutePath: memoryPath,
374+
agentIds: ["main"],
375+
contentType: "markdown",
376+
},
377+
]);
378+
const appConfig: OpenClawConfig = {
379+
agents: {
380+
list: [{ id: "main", default: true, workspace: workspaceDir }],
381+
},
382+
};
383+
const first = await syncMemoryWikiBridgeSources({ config, appConfig });
384+
const pagePath = first.pagePaths[0] ?? "";
385+
const pageAbsPath = path.join(vaultDir, pagePath);
386+
await fs.rm(pageAbsPath);
387+
await fs.mkdir(pageAbsPath);
388+
if (params.populateDirectory) {
389+
await fs.writeFile(path.join(pageAbsPath, "child.md"), "blocking child\n", "utf8");
390+
}
391+
await fs.writeFile(memoryPath, "# Updated Durable Memory\n", "utf8");
392+
return { appConfig, config, pageAbsPath };
393+
}
394+
395+
it("reports non-symlink bridge source write safety failures without symlink wording", async () => {
396+
const { appConfig, config } = await createDirectoryCollisionFixture({
397+
workspaceName: "not-file-workspace",
398+
vaultName: "not-file-vault",
399+
populateDirectory: true,
400+
});
401+
402+
const second = syncMemoryWikiBridgeSources({ config, appConfig });
403+
await expect(second).rejects.toThrow(
404+
/Refusing to write imported source page \((not-empty|not-file|path-mismatch)\): sources\//u,
405+
);
406+
await expect(second).rejects.not.toThrow("through symlink");
407+
});
408+
409+
it("does not remove empty directory bridge source collisions as hardlinks", async () => {
410+
const { appConfig, config, pageAbsPath } = await createDirectoryCollisionFixture({
411+
workspaceName: "empty-directory-workspace",
412+
vaultName: "empty-directory-vault",
413+
});
414+
415+
const second = syncMemoryWikiBridgeSources({ config, appConfig });
416+
await expect(second).rejects.toThrow(
417+
/Refusing to write imported source page \((not-file|path-mismatch)\): sources\//u,
418+
);
419+
await expect(second).rejects.not.toThrow("through symlink");
420+
await expect(fs.stat(pageAbsPath)).resolves.toSatisfy((stat) => stat.isDirectory());
421+
});
422+
349423
it("replaces bridge source page hardlinks without clobbering their target", async () => {
350424
const workspaceDir = await createBridgeWorkspace("hardlink-workspace");
351425
const { rootDir: vaultDir, config } = await createVault({

extensions/memory-wiki/src/source-page-shared.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,23 @@ import {
88

99
type ImportedSourceState = Parameters<typeof shouldSkipImportedSourceWrite>[0]["state"];
1010

11+
type FileStatLike = {
12+
isFile?: unknown;
13+
nlink?: unknown;
14+
};
15+
16+
function isRegularFileStat(value: unknown): value is FileStatLike & { nlink: number } {
17+
if (!value || typeof value !== "object") {
18+
return false;
19+
}
20+
const stat = value as FileStatLike;
21+
const isFile =
22+
typeof stat.isFile === "function"
23+
? (stat.isFile as () => boolean).call(stat)
24+
: stat.isFile === true;
25+
return isFile && typeof stat.nlink === "number";
26+
}
27+
1128
export async function writeImportedSourcePage(params: {
1229
vaultRoot: string;
1330
syncKey: string;
@@ -51,12 +68,18 @@ export async function writeImportedSourcePage(params: {
5168
const existing = pageStat ? await vault.readText(params.pagePath).catch(() => "") : "";
5269
if (existing !== rendered) {
5370
try {
54-
if (pageStat && pageStat.nlink > 1) {
71+
if (isRegularFileStat(pageStat) && pageStat.nlink > 1) {
5572
await vault.remove(params.pagePath);
5673
}
5774
await vault.write(params.pagePath, rendered);
5875
} catch (error) {
5976
if (error instanceof FsSafeError) {
77+
if (error.code !== "symlink" && error.code !== "path-alias") {
78+
throw new Error(
79+
`Refusing to write imported source page (${error.code}): ${params.pagePath}: ${error.message}`,
80+
{ cause: error },
81+
);
82+
}
6083
throw new Error(
6184
`Refusing to write imported source page through symlink: ${params.pagePath}`,
6285
{ cause: error },

0 commit comments

Comments
 (0)