Summary
memory-wiki silently discards the user's ## Notes human block when the re-read of the existing wiki page transiently fails during a source re-ingest. The generated content still lands, the page persists, and the sync fingerprint is updated, so the hand-written notes are lost permanently with no self-heal.
Environment
- Commit: 4ac5cf8 (origin/main at time of filing)
- Surface:
extensions/memory-wiki source ingest / wiki sync (page write path)
Steps to reproduce
- Ingest a source into memory-wiki so a source page exists (for example
sources/roadmap.md).
- Hand-edit the page's human-notes region (between
<!-- openclaw:human:start --> and <!-- openclaw:human:end -->).
- Change the source so the next ingest is not skipped, then re-ingest while a transient read failure hits the existing-page re-read (an
EIO, or the fs-safe path-mismatch concurrent-rewrite race that the wiki writer explicitly retries for: two syncs racing, or the user's editor saving mid-sync).
- Inspect the page: the new content is present but the
## Notes human block is empty.
Expected
A transient failure to re-read the existing page must not discard the user's hand-written notes. The content update should land and the human-notes block should survive (the writer path already retries this exact race).
Actual
The human-notes block is emptied. preserveHumanNotesBlock (added in #95614) is bypassed because the read result is coerced to an empty string, so the empty template overwrites the user's notes. The sync-state fingerprint advances, so the loss never self-heals on a later sync.
Root cause
Two swallow-read-then-overwrite sites conflate "existing page unreadable (transient)" with "no existing page":
extensions/memory-wiki/src/ingest.ts:90
const existing = created ? "" : await fs.readFile(pagePath, "utf8").catch(() => "");
await fs.writeFile(
pagePath,
existing ? preserveHumanNotesBlock(markdown, existing) : markdown,
"utf8",
);
extensions/memory-wiki/src/source-page-shared.ts:55
const existing = pageStat ? await vault.readText(params.pagePath).catch(() => "") : "";
const nextRendered = existing ? preserveHumanNotesBlock(rendered, existing) : rendered;
In both, .catch(() => "") turns a real read error into existing = "", which routes past preserveHumanNotesBlock into the empty-template write. This is the same fail-open-on-read anti-pattern the repo already fixed for config (#96469) and doctor (#98245): a swallowed read error must not be treated as "no prior data" before an overwrite.
Sibling surfaces
Both memory-wiki write paths share the flaw: ingest.ts:90 (source ingest) and source-page-shared.ts:55 (shared source-page writer used by wiki syncs). The sibling writer extensions/memory-wiki/src/vault-page-write.ts:24-32 documents and retries the exact transient path-mismatch concurrent-rewrite race, but the reads above swallow that same race instead of retrying, so under the precise condition the writer defends against, the read can wipe notes.
Distinct from #97523 and its open PRs (#97527, #97560), which only touch unsafe-local.ts: that bug prunes/recreates pages when the configured source path is unavailable. This one does not prune. The page persists and is rewritten with an empty notes block because the existing-page re-read failed. It is a hole in the #95614 fix, which only applies preserveHumanNotesBlock when the read succeeds.
Real behavior proof
Behavior addressed: transient existing-page read failure during re-ingest silently empties the user's human-notes block.
Real environment tested: drove the real ingestMemoryWikiSource end to end against a real on-disk vault at commit 4ac5cf8; the only seam mocked is a one-shot node:fs/promises.readFile failure on the existing-page re-read (an EIO, standing in for the transient path-mismatch race the writer retries); the vault, ingest, render, and write paths all stayed real.
Exact steps or command run after this patch: node scripts/run-vitest.mjs extensions/memory-wiki/src/wiki-notes-wipe-transient-read.test.ts, dumping the emitted ## Notes region on pristine origin/main versus the same inputs with the read failing closed (retry-once instead of swallow).
Evidence after fix:
# OBSERVED (buggy, origin/main 4ac5cf86): user note wiped
## Notes
<!-- openclaw:human:start -->
<!-- openclaw:human:end -->
# EXPECTED (correct behavior on identical inputs): note survives, v2 content still lands
## Notes
<!-- openclaw:human:start -->
KEY INSIGHT: covers the Q2 roadmap (irreplaceable human note)
<!-- openclaw:human:end -->
Observed result after fix: on origin/main the transient read failure empties the human-notes block while the regenerated content lands, permanently destroying the user's notes; failing the read closed preserves the note and still lands the content.
What was not tested: I did not fault-inject the source-page-shared.ts:55 sibling path directly (confirmed by inspection to share the identical .catch(() => "") swallow before preserveHumanNotesBlock), and I did not reproduce the underlying fs-safe path-mismatch race by racing two real concurrent syncs; the injected EIO stands in for that transient failure.
Summary
memory-wiki silently discards the user's
## Noteshuman block when the re-read of the existing wiki page transiently fails during a source re-ingest. The generated content still lands, the page persists, and the sync fingerprint is updated, so the hand-written notes are lost permanently with no self-heal.Environment
extensions/memory-wikisource ingest / wiki sync (page write path)Steps to reproduce
sources/roadmap.md).<!-- openclaw:human:start -->and<!-- openclaw:human:end -->).EIO, or the fs-safepath-mismatchconcurrent-rewrite race that the wiki writer explicitly retries for: two syncs racing, or the user's editor saving mid-sync).## Noteshuman block is empty.Expected
A transient failure to re-read the existing page must not discard the user's hand-written notes. The content update should land and the human-notes block should survive (the writer path already retries this exact race).
Actual
The human-notes block is emptied.
preserveHumanNotesBlock(added in #95614) is bypassed because the read result is coerced to an empty string, so the empty template overwrites the user's notes. The sync-state fingerprint advances, so the loss never self-heals on a later sync.Root cause
Two swallow-read-then-overwrite sites conflate "existing page unreadable (transient)" with "no existing page":
extensions/memory-wiki/src/ingest.ts:90extensions/memory-wiki/src/source-page-shared.ts:55In both,
.catch(() => "")turns a real read error intoexisting = "", which routes pastpreserveHumanNotesBlockinto the empty-template write. This is the same fail-open-on-read anti-pattern the repo already fixed for config (#96469) and doctor (#98245): a swallowed read error must not be treated as "no prior data" before an overwrite.Sibling surfaces
Both memory-wiki write paths share the flaw:
ingest.ts:90(source ingest) andsource-page-shared.ts:55(shared source-page writer used by wiki syncs). The sibling writerextensions/memory-wiki/src/vault-page-write.ts:24-32documents and retries the exact transientpath-mismatchconcurrent-rewrite race, but the reads above swallow that same race instead of retrying, so under the precise condition the writer defends against, the read can wipe notes.Distinct from #97523 and its open PRs (#97527, #97560), which only touch
unsafe-local.ts: that bug prunes/recreates pages when the configured source path is unavailable. This one does not prune. The page persists and is rewritten with an empty notes block because the existing-page re-read failed. It is a hole in the #95614 fix, which only appliespreserveHumanNotesBlockwhen the read succeeds.Real behavior proof
Behavior addressed: transient existing-page read failure during re-ingest silently empties the user's human-notes block.
Real environment tested: drove the real
ingestMemoryWikiSourceend to end against a real on-disk vault at commit 4ac5cf8; the only seam mocked is a one-shotnode:fs/promises.readFilefailure on the existing-page re-read (anEIO, standing in for the transientpath-mismatchrace the writer retries); the vault, ingest, render, and write paths all stayed real.Exact steps or command run after this patch:
node scripts/run-vitest.mjs extensions/memory-wiki/src/wiki-notes-wipe-transient-read.test.ts, dumping the emitted## Notesregion on pristine origin/main versus the same inputs with the read failing closed (retry-once instead of swallow).Evidence after fix:
Observed result after fix: on origin/main the transient read failure empties the human-notes block while the regenerated content lands, permanently destroying the user's notes; failing the read closed preserves the note and still lands the content.
What was not tested: I did not fault-inject the
source-page-shared.ts:55sibling path directly (confirmed by inspection to share the identical.catch(() => "")swallow beforepreserveHumanNotesBlock), and I did not reproduce the underlying fs-safepath-mismatchrace by racing two real concurrent syncs; the injectedEIOstands in for that transient failure.