Skip to content

memory-wiki: transient existing-page read failure during re-ingest silently wipes the user ## Notes block #98345

Description

@yetval

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

  1. Ingest a source into memory-wiki so a source page exists (for example sources/roadmap.md).
  2. Hand-edit the page's human-notes region (between <!-- openclaw:human:start --> and <!-- openclaw:human:end -->).
  3. 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).
  4. 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.

Metadata

Metadata

Assignees

Labels

P0Emergency: data loss, security bypass, crash loop, or unusable core runtime.clawsweeper:fix-shape-clearClawSweeper found a clear likely implementation shape for this issue.clawsweeper:queueable-fixClawSweeper marked this issue as an existing queue_fix_pr work candidate.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:data-lossCan lose, corrupt, or silently drop user/session/config data.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.no-staleExclude from stale automation

Type

No type

Fields

Priority

None yet

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions