openclaw - ✅(Solved) Fix [Bug]: refreshPageRelatedBlocks rewrites empty source pages as 107-byte tail-only stubs (replaceManagedMarkdownBlock empty-input short-circuit) [2 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#78121Fetched 2026-05-06 06:16:44
View on GitHub
Comments
1
Participants
2
Timeline
5
Reactions
2
Author
Timeline (top)
cross-referenced ×2labeled ×2commented ×1

refreshPageRelatedBlocks reads each non-report page from disk and pipes it through replaceManagedMarkdownBlock to update the ## Related section. There is no try/catch and no empty-input guard. When the page on disk is empty, replaceManagedMarkdownBlock short-circuits at line 32 and returns only the managed block — exactly 107 bytes — overwriting the empty file. shouldSkipImportedSourceWrite then preserves the stub indefinitely. In our vault, 10 source pages were silently stubbed in a single batch on 2026-04-28 14:53 UTC and remained that way for 7 days until manual audit caught them.

Root Cause

refreshPageRelatedBlocks reads each non-report page from disk and pipes it through replaceManagedMarkdownBlock to update the ## Related section. There is no try/catch and no empty-input guard. When the page on disk is empty, replaceManagedMarkdownBlock short-circuits at line 32 and returns only the managed block — exactly 107 bytes — overwriting the empty file. shouldSkipImportedSourceWrite then preserves the stub indefinitely. In our vault, 10 source pages were silently stubbed in a single batch on 2026-04-28 14:53 UTC and remained that way for 7 days until manual audit caught them.

Fix Action

Fixed

PR fix notes

PR #78125: fix: skip empty wiki pages during related refresh

Description (problem / solution / changelog)

Summary

Prevent memory wiki related-block refresh from rewriting zero-byte or whitespace-only pages into managed ## Related stubs. Empty pages are now left unchanged so the refresh path does not turn transient empty reads into persistent 107-byte source-page stubs.

Changes

  • Skip related-block writes when the current page content is empty or whitespace-only.
  • Add a regression test covering zero-byte and whitespace-only source pages during compileMemoryWikiVault.

Testing

  • PATH="/tmp/openclaw-pnpm-shim:$PATH" node scripts/test-projects.mjs extensions/memory-wiki/src/compile.test.ts
  • git diff --check
  • PATH="/tmp/openclaw-pnpm-shim:$PATH" node scripts/check-changed.mjs

Fixes openclaw/openclaw#78121

Changed files

  • extensions/memory-wiki/src/compile.test.ts (modified, +33/-0)
  • extensions/memory-wiki/src/compile.ts (modified, +3/-0)

PR #78127: fix(memory-wiki): skip empty source pages in refreshPageRelatedBlocks (#78121)

Description (problem / solution / changelog)

Problem

Closes #78121.

refreshPageRelatedBlocks reads each non-report wiki page from disk and pipes it through replaceManagedMarkdownBlock to update the ## Related section. When a page is empty (zero bytes or whitespace-only), replaceManagedMarkdownBlock's empty-input short-circuit at line 52 returns only the managed block (~107 bytes), overwriting the empty file with a stub containing no frontmatter, no title, and no content. shouldSkipImportedSourceWrite then preserves the stub indefinitely because the destination fingerprint stabilizes, leaving the page invisible to wiki_search, RAG, and Obsidian backlinks.

The affected code path (compileMemoryWikiVaultrefreshPageRelatedBlocks) runs on every wiki.search, wiki.compile, and openclaw wiki compile invocation, so the stub persists until the user manually deletes the destination file.

Fix

Add an early-continue guard in refreshPageRelatedBlocks: if the page content is empty after trim(), skip it without calling replaceManagedMarkdownBlock. Empty pages are left intact — no stub is written.

// Before
const original = await fs.readFile(page.absolutePath, "utf8");
const updated = withTrailingNewline(replaceManagedMarkdownBlock({ ... }));

// After
const original = await fs.readFile(page.absolutePath, "utf8");
if (original.trim().length === 0) {
  continue;
}
const updated = withTrailingNewline(replaceManagedMarkdownBlock({ ... }));

Files changed

FileChange
extensions/memory-wiki/src/compile.tsAdd empty-content guard before replaceManagedMarkdownBlock in refreshPageRelatedBlocks
extensions/memory-wiki/src/compile.test.tsRegression test: compile with an empty source page alongside a valid one; verify empty page remains "" after compile

Tests

Tests  9 passed (9)   # 8 pre-existing + 1 new regression test

🤖 Generated with Claude Code

Changed files

  • extensions/memory-wiki/src/compile.test.ts (modified, +26/-0)
  • extensions/memory-wiki/src/compile.ts (modified, +3/-0)

Code Example

## Code reference (in published bundle, version 2026.5.2)

### File: dist/memory-host-markdown-CJK-ANEy.js (around line 32)


function replaceManagedMarkdownBlock(params) {
  const managedBlock = `${params.heading ? `${params.heading}\n` : ""}${params.startMarker}\n${params.body}\n${params.endMarker}`;
  // ... regex setup ...
  const matches = Array.from(params.original.matchAll(existingPattern));
  if (matches.length > 0) {
    // replace existing block in-place; preserves rest of file
    return updated + params.original.slice(lastEnd);
  }
  const trimmed = params.original.trimEnd();
  if (trimmed.length === 0) return `${managedBlock}\n`;             // ← bug trigger
  return `${trimmed}\n\n${managedBlock}\n`;                          // ← normal append
}


### File: dist/cli-CTvVbV1J.js (around line 971)


async function refreshPageRelatedBlocks(params) {
  if (!params.config.render.createBacklinks) return [];
  const updatedFiles = [];
  for (const page of params.pages) {
    if (page.kind === "report") continue;
    const original = await fs$1.readFile(page.absolutePath, "utf8");           // ← no try/catch, no empty-input guard
    const updated = withTrailingNewline(replaceManagedMarkdownBlock({
      original,
      heading: "## Related",
      startMarker: WIKI_RELATED_START_MARKER,
      endMarker: WIKI_RELATED_END_MARKER,
      body: buildRelatedBlockBody({ config: params.config, page, allPages: params.pages })
    }));
    if (updated === original) continue;
    await fs$1.writeFile(page.absolutePath, updated, "utf8");                  // ← writes the 107-B stub
    updatedFiles.push(page.absolutePath);
  }
  return updatedFiles;
}


### Top-level entry: dist/cli-CTvVbV1J.js line 1267 — compileMemoryWikiVault

compileMemoryWikiVault calls refreshPageRelatedBlocks with the result of readPageSummaries. readPageSummaries filters out malformed pages but still includes pages whose existence on disk is verified — a zero-byte file is included if its name matches the page-path conventions.

## Persistence (why these stubs accumulate silently)

shouldSkipImportedSourceWrite (cli dist/cli-CTvVbV1J.js line 3216) controls whether the next sync re-writes a page from its source. Conditions for skipping:

1. an entry exists in source-sync.json,
2. all of pagePath, sourcePath, sourceUpdatedAtMs, sourceSize, renderFingerprint match,
3. fs.access(pagePath) succeeds (destination exists).

Once a stub is on disk, condition 3 stays true and conditions 12 stay true (source content unchanged), so shouldSkipImportedSourceWrite keeps returning true indefinitely. Same dynamic flagged in #64696 section 5.

## Recovery procedure that worked

1. Identify stubs: Get-ChildItem $vault\sources -File | Where-Object Length -EQ 107
2. Snapshot them.
3. Delete the stubs at destination paths (atomic writer is correct; safe to delete).
4. Force re-sync: openclaw wiki unsafe-local import (or any wiki_search call).
5. Verify: no 107-byte files remain in sources/.

In our case: single node openclaw.mjs wiki unsafe-local import --json after deletion produced imported=10 updated=0 skipped=247 removed=0 in 18 seconds, with all 10 destinations re-rendered to their proper 1.739 KB sizes.
RAW_BUFFERClick to expand / collapse

Bug type

Behavior bug (incorrect output/state without crash)

Beta release blocker

No

Summary

refreshPageRelatedBlocks reads each non-report page from disk and pipes it through replaceManagedMarkdownBlock to update the ## Related section. There is no try/catch and no empty-input guard. When the page on disk is empty, replaceManagedMarkdownBlock short-circuits at line 32 and returns only the managed block — exactly 107 bytes — overwriting the empty file. shouldSkipImportedSourceWrite then preserves the stub indefinitely. In our vault, 10 source pages were silently stubbed in a single batch on 2026-04-28 14:53 UTC and remained that way for 7 days until manual audit caught them.

Steps to reproduce

  1. Have one or more empty (zero-byte or whitespace-only) source pages in the wiki vault. This can result from any external factor — file-locking during a previous write (Obsidian Local REST API, IDE auto-save, antivirus), partial copy or interrupted move during vault migration, manual editing in an external tool that saves an empty file, or filesystem-level interrupt.
  2. Trigger any path that calls compileMemoryWikiVault. The most common: agent runs wiki_search → wiki.search → syncImportedSourcesIfNeeded$1 → compileMemoryWikiVault. Other entry points: wiki.compile gateway method, openclaw wiki compile CLI, wiki.unsafeLocal.import, or successful ingest events in syncMemoryWikiUnsafeLocalSources.
  3. Observe: the empty page is rewritten to a 107-byte file containing only the managed ## Related block. shouldSkipImportedSourceWrite skips the file on all subsequent syncs (source mtime/size/fingerprint unchanged + destination exists), so the stub persists indefinitely.

Expected behavior

An empty/whitespace-only source page should not be rewritten into a managed-block-only stub. Either the empty page should be left alone with a log line indicating it was skipped, or it should be repaired from the source artifact. replaceManagedMarkdownBlock's empty-input short-circuit conflates "create a managed-block-only file from scratch" with "rewrite an existing empty file" — the latter is rarely the right answer.

Actual behavior

The empty page is overwritten with exactly 107 bytes containing only the managed ## Related block:

Related

<!-- openclaw:wiki:related:start -->
  • No related pages yet.
<!-- openclaw:wiki:related:end -->

No frontmatter, no title, no content, no notes. Full prior content is gone from the vault representation. Source files at canonical paths remain intact, but the wiki representation is empty. Page is invisible to wiki_search, RAG, and Obsidian backlinks for the entire window until the destination is manually deleted.

OpenClaw version

2026.5.2

Operating system

Windows 11

Install method

npm global

Model

N/A — bug is in cli memory-wiki code path, not model-specific. Affected agents in our setup ran claude-haiku-4-5 via direct anthropic provider.

Provider / routing chain

N/A — bug is in cli memory-wiki code path, not provider-specific. Standard openclaw -> anthropic:direct routing in our setup.

Additional provider/model setup details

Not relevant to this bug — the bug fires regardless of provider/model setup. The triggering agent operations were standard wiki_search calls from haiku-4-5 sessions, but any model with wiki_search tool access would surface the same behavior.

Logs, screenshots, and evidence

## Code reference (in published bundle, version 2026.5.2)

### File: dist/memory-host-markdown-CJK-ANEy.js (around line 32)


function replaceManagedMarkdownBlock(params) {
  const managedBlock = `${params.heading ? `${params.heading}\n` : ""}${params.startMarker}\n${params.body}\n${params.endMarker}`;
  // ... regex setup ...
  const matches = Array.from(params.original.matchAll(existingPattern));
  if (matches.length > 0) {
    // replace existing block in-place; preserves rest of file
    return updated + params.original.slice(lastEnd);
  }
  const trimmed = params.original.trimEnd();
  if (trimmed.length === 0) return `${managedBlock}\n`;             // ← bug trigger
  return `${trimmed}\n\n${managedBlock}\n`;                          // ← normal append
}


### File: dist/cli-CTvVbV1J.js (around line 971)


async function refreshPageRelatedBlocks(params) {
  if (!params.config.render.createBacklinks) return [];
  const updatedFiles = [];
  for (const page of params.pages) {
    if (page.kind === "report") continue;
    const original = await fs$1.readFile(page.absolutePath, "utf8");           // ← no try/catch, no empty-input guard
    const updated = withTrailingNewline(replaceManagedMarkdownBlock({
      original,
      heading: "## Related",
      startMarker: WIKI_RELATED_START_MARKER,
      endMarker: WIKI_RELATED_END_MARKER,
      body: buildRelatedBlockBody({ config: params.config, page, allPages: params.pages })
    }));
    if (updated === original) continue;
    await fs$1.writeFile(page.absolutePath, updated, "utf8");                  // ← writes the 107-B stub
    updatedFiles.push(page.absolutePath);
  }
  return updatedFiles;
}


### Top-level entry: dist/cli-CTvVbV1J.js line 1267 — compileMemoryWikiVault

compileMemoryWikiVault calls refreshPageRelatedBlocks with the result of readPageSummaries. readPageSummaries filters out malformed pages but still includes pages whose existence on disk is verified — a zero-byte file is included if its name matches the page-path conventions.

## Persistence (why these stubs accumulate silently)

shouldSkipImportedSourceWrite (cli dist/cli-CTvVbV1J.js line 3216) controls whether the next sync re-writes a page from its source. Conditions for skipping:

1. an entry exists in source-sync.json,
2. all of pagePath, sourcePath, sourceUpdatedAtMs, sourceSize, renderFingerprint match,
3. fs.access(pagePath) succeeds (destination exists).

Once a stub is on disk, condition 3 stays true and conditions 1–2 stay true (source content unchanged), so shouldSkipImportedSourceWrite keeps returning true indefinitely. Same dynamic flagged in #64696 section 5.

## Recovery procedure that worked

1. Identify stubs: Get-ChildItem $vault\sources -File | Where-Object Length -EQ 107
2. Snapshot them.
3. Delete the stubs at destination paths (atomic writer is correct; safe to delete).
4. Force re-sync: openclaw wiki unsafe-local import (or any wiki_search call).
5. Verify: no 107-byte files remain in sources/.

In our case: single node openclaw.mjs wiki unsafe-local import --json after deletion produced imported=10 updated=0 skipped=247 removed=0 in 18 seconds, with all 10 destinations re-rendered to their proper 1.7–39 KB sizes.

Impact and severity

Affected: 10 source pages in our wiki vault (memory/archive/2026-03/{09,11,15,16,17}.md, memory/{2026-04-11, 2026-04-19, 2026-04-22, 2026-04-26}.md, memory/council-state.json) Severity: Medium-high — silent data invisibility. Underlying memory was preserved at canonical paths, but vault representation was empty. Frequency: Rare in practice (~3% of agent-driven sync events in our 30-run sample), but creation point is one-shot per affected file. Persistence is indefinite once created. Consequence: ~84 KB of source content invisible to wiki_search/RAG/backlinks for 7 days until manual audit caught it. Detection signal: zero — no log line, no compile failure, no doctor warning.

Additional information

Suggested fix

Two complementary guards. Either alone closes the bug; both together is belt-and-suspenders.

Option A (preferred — fixes the WRITE path): In refreshPageRelatedBlocks, refuse to write if the source page on disk is empty/whitespace-only, and log it so the operator notices. Skip with reason: "empty-page".

Option B (defense-in-depth — fixes the LIBRARY): In replaceManagedMarkdownBlock, the original.trim().length === 0 branch should refuse rather than emit a managed-block-only output. Either throw, return null, or return the original verbatim. Current behavior conflates "create a managed-block-only file from scratch" with "rewrite an existing empty file."

If option B is taken, callers that genuinely want create-from-scratch behavior (e.g., writeManagedMarkdownFile at dist/cli-CTvVbV1J.js:1002, which uses # title\n as a fallback) should pass that fallback explicitly.

Option C (complementary, addresses persistence — already proposed in #64696): shouldSkipImportedSourceWrite should validate that the destination contains source-page frontmatter, not just that the file exists.

Related issues

  • #75491 [closed, fixed in 2026.5.2]: replaceManagedMarkdownBlock CRLF + missing g flag → duplicate blocks. Same function, different code path, opposite symptom (file grows vs file shrinks). Not a duplicate.
  • #64696 [open] section 5: malformed-page skip hardening. Complementary — addresses persistence side; this filing addresses creation side.

How the empty pages got there

Cannot fully reconstruct. The 2026-04-28 ingest event ran 2 days after a vault-path migration. Most plausible mechanism: subset of source-page files briefly held empty during migration window (concurrent file lock from Obsidian Local REST API, partial copy from Move-Item, or similar external interrupt). The atomic writer (writeFileAtomicInVault at dist/cli-CTvVbV1J.js:3278) is correct and would not have produced empty files itself.

extent analysis

TL;DR

The most likely fix for the issue is to add a guard in refreshPageRelatedBlocks to refuse writing to empty or whitespace-only source pages and log the event, or modify replaceManagedMarkdownBlock to handle empty input by refusing to emit a managed-block-only output.

Guidance

  • Implement Option A: In refreshPageRelatedBlocks, check if the source page is empty or whitespace-only before writing, and log the event if so.
  • Consider Option B: Modify replaceManagedMarkdownBlock to handle empty input by either throwing an error, returning null, or returning the original content verbatim.
  • Review Option C: Enhance shouldSkipImportedSourceWrite to validate the presence of source-page frontmatter in the destination file, addressing the persistence issue.
  • Verify the fix by checking that empty pages are no longer overwritten with managed-block-only content and that the wiki representation is correctly updated.

Example

// Example of Option A implementation in refreshPageRelatedBlocks
if (original.trim().length === 0) {
  console.log(`Skipping empty page: ${page.absolutePath}`);
  continue;
}

Notes

The provided code snippets and suggestions are based on the given issue content and may require adjustments according to the actual implementation and requirements of the OpenClaw project.

Recommendation

Apply Option A as the primary fix, as it directly addresses the issue of overwriting empty pages with managed-block-only content. This approach provides a clear and immediate solution to the problem described.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

FAQ

Expected behavior

An empty/whitespace-only source page should not be rewritten into a managed-block-only stub. Either the empty page should be left alone with a log line indicating it was skipped, or it should be repaired from the source artifact. replaceManagedMarkdownBlock's empty-input short-circuit conflates "create a managed-block-only file from scratch" with "rewrite an existing empty file" — the latter is rarely the right answer.

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

openclaw - ✅(Solved) Fix [Bug]: refreshPageRelatedBlocks rewrites empty source pages as 107-byte tail-only stubs (replaceManagedMarkdownBlock empty-input short-circuit) [2 pull requests, 1 comments, 2 participants]