Skip to content

fix(plugin): close dirty-closed rescan gaps: cursor starvation, cancelled-drain settlement, mid-drain re-emission - #2588

Open
syzsunshine219 wants to merge 2 commits into
MemTensor:mainfrom
akushonkamen:fix/2459-dirty-closed-rescan
Open

syzsunshine219 wants to merge 2 commits into
MemTensor:mainfrom
akushonkamen:fix/2459-dirty-closed-rescan

Conversation

@syzsunshine219

@syzsunshine219 syzsunshine219 commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

Restores the change proposed in #2482 for review against main after its former base branch, dev-v2.0.36, was deleted. The source commits and authors are preserved.

Source head: c70019cf99b938f1b66726f03b6d7dafff7ecbe4

This PR is open for review. Review and CI against the current base are pending; validation claims in the original description below refer to the earlier snapshot. The original discussion and evidence remain in #2482.


Original change description

Description

Fixes #2459 — two reliability gaps in the memos-local-plugin dirty-closed rescan, plus one follow-up guard found in review. All changes are in apps/memos-local-plugin/core/pipeline/memory-core.ts with new unit tests; both commits are based on dev-v2.0.36 (8511502).

Root cause

  • Gap 1 (cursor starvation): collectDirtyClosedEpisodes() paged only downward via listClosedPage({ before: cursor }) and only reset the cursor on a short page, so an episode that became dirty after the cursor paged past its position was never revisited until the walk reached the end of the closed set and the cursor reset — a full lap of ceil(closed/500) × 10 min (~70 min on the reporter's DB, hours on the 30k+-episode DBs of v2.0.2+ regression: bootstrapMemoryCoreFull() hangs with 100% CPU on databases >500MB #1787).
  • Gap 2 (interrupted drain lost the whole batch's phase-2): recoverDirtyClosedEpisodes() returned right after await handle.flush() when startupRecoveryCancelled was set, skipping settlement for every queued episode: successes kept their rewardDirty markers (re-collected and re-run later — duplicate LLM spend) and failures lost their failedAttempts backoff counters.

Fix

  • Gap 1: every scan first takes one bounded newest-first head page (before: null) merged into an id-deduplicated collected map, then continues the cursor walk (core/pipeline/memory-core.ts:1788-1831). Cursor advancement still derives from the walk page only (core/pipeline/memory-core.ts:1823-1831), so head-page rows do not disturb cursor semantics; freshly closed / freshly re-marked rows are now seen on the very next tick.
  • Gap 2: phase-2 settlement moved into try { flush } finally with one try/catch per episode (core/pipeline/memory-core.ts:1700-1775) — cancel or a flush throw mid-teardown still settles each row (clear rewardDirty on success, bump failedAttempts + lastFailureAt on failure); a per-row error logs init.dirty_closed_episodes.settle_failed and cannot drop the rest of the batch.
  • Review follow-up (second commit): recoverDirtyClosedEpisodes records each emitted episode id in a process-local in-flight set before emitting and clears it as soon as that row settles; both auto-collection sites (init classification core/pipeline/memory-core.ts:811-825 and the 10-min periodic rescan core/pipeline/memory-core.ts:1215-1227) filter in-flight ids, so a tick racing an open drain can no longer re-collect and re-emit the whole batch (duplicate LLM spend). Freshly dirtied rows are not blocked — per-episode guard, not a whole-scan mutex.
  • init.orphan_scan.failed raised from debug to warn (core/pipeline/memory-core.ts:1219-1226): a skipped startup classification used to be invisible at default log levels.

AI assistance disclosure: this change was produced with AI assistance in an automated contributor workflow; every command and test result below was executed locally as recorded.

Type of change

Please delete options that are not relevant.

  • Bug fix (non-breaking change which fixes an issue)

How Has This Been Tested?

  • Unit Test
  • Test Script Or Test Steps (please provide)

Environment: macOS (arm64), Node + vitest 2.1.9, run from apps/memos-local-plugin.

Reproduce:

cd apps/memos-local-plugin
# targeted issue-#2459 cases (2 new in commit 1, 1 more in commit 2)
npx vitest run tests/unit/pipeline/memory-core.test.ts --reporter=dot -t "issue #2459"
# regression subset
npx vitest run tests/unit/pipeline/memory-core.test.ts tests/unit/startup-recovery.test.ts tests/unit/reward/ --reporter=dot
# full unit / integration / typecheck
npx vitest run tests/unit --reporter=dot
npx vitest run tests/integration --reporter=dot
npx tsc -p tsconfig.json --noEmit

Results (final verification at this branch head):

Note: the ticket's vitest … -q flag does not exist in vitest 2.1.9 (CACError: Unknown option -q), so --reporter=dot was used instead. CI is expected red on this scope for external PRs due to the known environment issue tracked in #2476 (autotest environment preparation fails); local green evidence is provided above.

Checklist

  • I have performed a self-review of my own code | 我已自行检查了自己的代码
  • I have commented my code in hard-to-understand areas | 我已在难以理解的地方对代码进行了注释
  • I have added tests that prove my fix is effective or that my feature works | 我已添加测试以证明我的修复有效或功能正常
  • I have created related documentation issue/PR in MemOS-Docs (if applicable) | 我已在 MemOS-Docs 中创建了相关的文档 issue/PR(如果适用)
  • I have linked the issue to this PR (if applicable) | 我已将 issue 链接到此 PR(如果适用)
  • I have mentioned the person who will review this PR | 我已提及将审查此 PR 的人

Reviewer Checklist

  • closes #xxxx (Replace xxxx with the GitHub issue number)
  • Made sure Checks passed
  • Tests have been provided

…ncelled-drain settlement

Issue MemTensor#2459 reported two reliability gaps in the dirty-closed rescan:

Gap 1 — cursor-lap starvation. collectDirtyClosedEpisodes() paged only
downward (listClosedPage({ before: cursor })), so an episode that became
dirty after the cursor paged past its position was not revisited until
the walk reached the end of the closed set and the cursor reset — a full
lap of ceil(closed/500) x 10 min (~70 min on the reporter's DB, hours on
the 30k+-episode DBs of MemTensor#1787). Each scan now takes one bounded
newest-first page from the head (before: null) before continuing the
cursor walk, deduplicated by episode id, so freshly closed episodes and
freshly re-marked recent rows are seen on the very next tick while the
cursor still drains the older backlog a page at a time.

Gap 2 — interrupted drains lost phase-2 accounting for the whole batch.
recoverDirtyClosedEpisodes() returned right after await handle.flush()
when startupRecoveryCancelled was set, skipping settlement for every
queued episode: successes kept their rewardDirty markers (re-collected
and re-run later — duplicate LLM spend) and failures lost their
failedAttempts backoff counters. Settlement is synchronous bookkeeping
on rows whose chains already ran, so it now runs even when cancelled and
even if the flush throws mid-teardown, best-effort with one try/catch
per row (init.dirty_closed_episodes.settle_failed).

Also raises init.orphan_scan.failed from debug to warn: a skipped
startup classification used to be invisible at default log levels (the
reporter only found it by diffing kv cursor writes).

Base: dev-v2.0.36. Fixes MemTensor#2459
Signed-off-by: akushonkamen <akushonkamen@163.com>
…-drain

Review finding on MemTensor#2459: the head-page pass from gap 1 re-reads the
newest page on every 10-minute tick, and episodes whose drain is still
in flight keep their rewardDirty markers until phase-2 settles — so a
tick racing an open drain re-collected and re-emitted the whole batch,
re-running the full reward chain per episode every tick (~5-6 duplicate
runs per episode for the 300+-episode / 1h+-drain scenario the issue
describes; neither the 30s scan dedup nor the capture/reward
subscribers dedupe by episodeId).

Guard: recoverDirtyClosedEpisodes records each emitted episode id in a
closure-level in-flight set before emitting and clears it as soon as
that row's settlement runs (success, backoff bump, or un-settleable);
both auto collection sites (init classification + periodic rescan)
filter in-flight ids out. Per-episode, not a whole-scan mutex, so
freshly dirtied rows are still collected on the very same tick while a
large drain is open. The set is process-local: after a hard kill the
markers on disk still drive a normal rescan on reboot.

Base: dev-v2.0.36. Refs MemTensor#2459
Signed-off-by: akushonkamen <akushonkamen@163.com>
@Memtensor-AI Memtensor-AI added area:plugin OpenClaw & Hermes status:in-progress Someone or AI is working on it | 人工或 AI 正在处理 labels Nov 9, 2026
@syzsunshine219
syzsunshine219 marked this pull request as ready for review November 9, 2026 15:48
@Memtensor-AI

Copy link
Copy Markdown
Collaborator

🤖 Open Code Review

Target: PR #2588
Task: ee8df50e3859b006
Base: main
Head: fix/2459-dirty-closed-rescan
Head SHA: c70019cf99b938f1b66726f03b6d7dafff7ecbe4

🔍 OpenCodeReview found 5 issue(s) in this PR.


1. apps/memos-local-plugin/core/pipeline/memory-core.ts (L1814-L1817)

When a stored cursor is in play and the cursor page comes back empty (walk finished, closed set is an exact multiple of DIRTY_CLOSED_SCAN_PAGE_SIZE, or the rows were deleted), the code below hits if (page.length === 0) { ...; return []; } and discards every row just gathered from the newest head page. That is exactly the moment the freshness fix matters, so freshly dirtied rows still wait an extra scan interval, and the stale rewardDirty cleanup loop and return [...collected.values()] are skipped too. Restructure that branch so it resets the cursor without returning early, e.g. if (page.length === 0) { handle.repos.kv.del(...); } else if (page.length < SIZE) { ... } else { ...set cursor... }, and let the function fall through to return [...collected.values()].


2. apps/memos-local-plugin/core/pipeline/memory-core.ts (L1711)

The inverse cleanup for these ids only lives in the finally attached to try { await handle.flush(); }, which starts after this emit loop. If updateMeta/session.emit throws for one row (storage error, teardown race), the function rejects before reaching that try, leaving every already-added id in dirtyRescoreInFlight for the process lifetime; both the init drain and the 10-min scan then permanently filter those episodes out, silently stopping their rescoring. Move the whole for (const ep of episodes) loop inside the existing try { ... await handle.flush(); } finally { ... } block so the per-row dirtyRescoreInFlight.delete(episodeId) always runs (the priorFailedAttempts map is populated before add, so it already covers every added id).


3. apps/memos-local-plugin/core/pipeline/memory-core.ts (L1753-L1756)

Settlement now runs unconditionally in finally, including when handle.flush() threw mid-teardown or startupRecoveryCancelled is set (shutdown-timeout path). In those cases the chains were aborted, rows are still dirty, and failedAttempts is bumped as if the LLM call had failed. Since dirtyEpisodeBackoffElapsed switches to exponential backoff once failedAttempts >= MAX_DIRTY_REWARD_ATTEMPTS (1h → 24h), a few interrupted shutdowns push legitimate episodes into long backoff after restart. Consider still clearing the marker for rows that became clean, but skipping the prevAttempts + 1 / lastFailureAt bump when the drain was cancelled or the flush threw.


4. apps/memos-local-plugin/core/pipeline/memory-core.ts (L1799-L1802)

The newest page is now re-read on every collect pass (boot + each 10-min tick), and episodeRewardIsDirty() is not cheap for rows that already have meta.reward.traceCount: it issues a synchronous traces.countExisting(...) COUNT query per row (see issue #1966 path). Rows in the head page are mostly already-clean episodes that used to be re-evaluated only once per cursor lap; with this change they cost up to DIRTY_CLOSED_SCAN_PAGE_SIZE extra blocking SQLite queries per tick against the single-threaded event loop that #1808/#1776 are about. On top of that, the drain now carries head page + cursor page (up to 2 × page size) into one handle.flush(), doubling the reward/L2/L3 batch that init detached precisely to avoid long blocking work. Suggest a smaller dedicated head-page limit (the fix only needs the handful of newest rows / freshly re-marked ones) and/or a cheap pre-filter (hasRewardDirtyMarker / rTask == null) before running the expensive dirty predicate.

💡 Suggested Change

Before:

    const headPage = handle.repos.episodes.listClosedPage({
      limit: DIRTY_CLOSED_SCAN_PAGE_SIZE,
      before: null,
    });

After:

    const headPage = handle.repos.episodes.listClosedPage({
      limit: DIRTY_CLOSED_SCAN_HEAD_PAGE_SIZE,
      before: null,
    });

5. apps/memos-local-plugin/core/pipeline/memory-core.ts (L1219)

This guard only becomes effective once recoverDirtyClosedEpisodes() starts emitting (ids are registered inside the emit loop), but the batch classified here is run after await recoverOpenEpisodesAsSessionEnd(stale) in startupRecoveryPromise. That stale pass runs full reward/reflect chains for up to 500 orphans and can easily outlast the 10-minute periodic interval, so autoRescoreDirtyClosedEpisodes() can re-collect the very same still-dirty rows (they are not marked in flight yet) and start a second concurrent drain — re-emitting episode.finalized and paying the LLM twice, which is exactly the duplicate spend this guard is meant to prevent. Consider claiming the classified batch in dirtyRescoreInFlight at classification time (with the settle path releasing it, so the early-throw cleanup noted above also has to cover it), or having the drain skip ids that were already claimed by another drain before it starts emitting.

Generated by cloud-assistant via Open Code Review.

@Memtensor-AI

Copy link
Copy Markdown
Collaborator

⚠️ Automated Test Results: ENV ISSUE

The test environment encountered an issue that requires manual attention.

Details: Executor error: Node dependency preparation failed for memos_local_plugin: Command failed with exit code 1: npm ci --include=dev --no-audit --no-fund
stdout:
stderr: npm warn deprecated boolean@3.2.0: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
npm error code 1
npm error path /data/test-workspaces/ee8df50e3859b006/repo/apps/memos-local-plugin/node_modules/onnxruntime-node
npm error command failed
npm error command sh -c node ./script/install
npm error /data/test-workspaces/ee8df50e3859b006/repo/apps/memos-local-plugin/node_modules/onnxruntime-node/script/install-utils.js:57
npm error reject(new Error(Failed to download build list. HTTP status code = ${statusCode}));
npm error ^
npm error
npm error Error: Failed to download build list. HTTP status code = 302
npm error at ClientRequest. (/data/test-workspaces/ee8df50e3859b006/repo/apps/memos-local-plugin/node_modules/onnxruntime-node/script/install-utils.js:57:18)
npm error at Object.onceWrapper (node:events:634:26)
npm error at ClientRequest.emit (node:events:519:28)
npm error at ClientRequest.emit (node:domain:489:12)
npm error at HTTPParser.parserOnIncomingClient (node:_http_client:780:27)
npm error at HTTPParser.parserOnHeadersComplete (node:_http_common:125:17)
npm error at TLSSocket.socketOnData (node:_http_client:615:22)
npm error at TLSSocket.emit (node:events:519:28)
npm error at TLSSocket.emit (node:domain:489:12)
npm error at addChunk (node:internal/streams/readable:561:12)
npm error
npm error Node.js v22.22.2
npm notice
npm notice New major version of npm available! 10.9.7 -> 12.2.2
npm notice Changelog: https://github.com/npm/cli/releases/tag/v12.2.2
npm notice To update run: npm install -g npm@12.2.2
npm notice
npm error A complete log of this run can be found in: /data/test-workspaces/_shared/npm-downloads/_logs/2026-11-09T15_58_38_773Z-debug-0.log
Branch: fix/2459-dirty-closed-rescan

Tested source: c70019cf99b938f1b66726f03b6d7dafff7ecbe4
Tested base: 3a16e5fe3048a209f722806f4123fa2e533588fd
Tested commit: fb50990da813e77746c73ae54224a4a3544c56a2
These results apply only to this immutable source/base snapshot.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:plugin OpenClaw & Hermes status:in-progress Someone or AI is working on it | 人工或 AI 正在处理

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] dirty-closed rescan misses freshly-marked episodes for a full cursor lap; interrupted drains lose phase-2 accounting

4 participants