Pre-submission checklist | 提交前检查
Bug Description | 问题描述
meta.rewardDirty is written when an episode is reopened, but if that episode subsequently reaches a terminal state through a path other than the ones that clear the marker, the marker is never removed. It is then indistinguishable from a live dirty state to both episodeRewardIsDirty() and a consistency watchdog built on json_type(meta_json,'$.rewardDirty') IS NOT NULL, so the episode is reported as dirty on every run — indefinitely — while being fully resolved.
Observed on a live installation: episode ep_ewndcv3q2ngk, 7 traces.
- scored heuristically and terminal-skipped —
meta.reward.skipped: true, traceCount: 2 (1 turn, below the 2-exchange minimum)
- reopened 89 s later —
meta.rewardDirty.reason: "episode_reopened", reopenedFor: "follow_up"
- later recovered via
recoveryReason: "dirty_reward_rescore", closeReason: "finalized", rTask = 0.0
json_type(meta_json,'$.rewardDirty') still returns object 17 days later:
{"reason":"episode_reopened","reopenedFor":"follow_up","at":<ts>,"previousRTask":0,"previousScoredAt":<ts>}
Manual json_remove(meta_json,'$.rewardDirty') clears every symptom immediately. Note that json_set(meta_json,'$.rewardDirty',NULL) does not — json_type then returns 'null', which is still IS NOT NULL, so any check written as a null-test keeps firing.
How to Reproduce | 如何重现
Not yet reproduced deterministically; the above is a live record. The ordering that appears to be required:
- Episode is scored and terminal-skipped → clears the marker (
core/reward/reward.ts:135).
- Episode is reopened while
rTask != null || meta.reward → re-sets the marker (core/session/episode-manager.ts:337). The skip-time clear has already run and will not run again.
- Episode later reaches a terminal state through the recovery path, which finalizes it without touching the marker.
- Marker persists.
Expected: reaching a terminal/recovered state clears rewardDirty regardless of which path produced it.
Actual: marker persists; the episode is reported dirty forever.
Relevant code (verified against main, de806942)
Set:
core/session/episode-manager.ts:337 — on reopen, gated on snap.rTask != null || snap.meta.reward
Clear:
core/pipeline/memory-core.ts:1720 — clears only inside the prior-failed-attempts rescan loop, and only for episodes that pass through it this pass
core/pipeline/memory-core.ts:1757 — clears skipped && hasRewardDirtyMarker rows, but over a bounded page
core/reward/reward.ts:135 and :276 — clear at the moment a skip/terminal reward is written
The last three all clear at a specific moment or over a bounded page. Step 2 above re-dirties the episode after the moment-clear has passed, and the remaining two are the only later opportunities — so an episode that misses both keeps the marker permanently. Whichever of those two should have caught this row did not, which is the part worth confirming on the maintainer side; the durable fix is probably to drop the marker wherever the episode reaches a terminal reward state, rather than at each site that can write one.
Environment | 环境信息
- memos-local-plugin 2.0.16
algorithm.lightweightMemory.enabled: false
- Linux (Debian-based), Node 22
- re-verified against upstream
main @ de806942
Additional Context | 其他信息
Impact: an operator-facing consistency check reports the row on every run forever. That trains the reader to ignore the check, which is the same class of observability decay that made a separate monitoring review misdiagnose frozen metrics.
Relevant prior issues reviewed: #1847, #1784, #2120, #1966 — all different failure modes (dirty-scan/skip-path fixes, abandoned-episode skip, dirty-closed pagination, ghost-trace over-scoring). I could not find an existing report of the marker surviving on a resolved episode.
Pre-submission checklist | 提交前检查
Bug Description | 问题描述
meta.rewardDirtyis written when an episode is reopened, but if that episode subsequently reaches a terminal state through a path other than the ones that clear the marker, the marker is never removed. It is then indistinguishable from a live dirty state to bothepisodeRewardIsDirty()and a consistency watchdog built onjson_type(meta_json,'$.rewardDirty') IS NOT NULL, so the episode is reported as dirty on every run — indefinitely — while being fully resolved.Observed on a live installation: episode
ep_ewndcv3q2ngk, 7 traces.meta.reward.skipped: true,traceCount: 2(1 turn, below the 2-exchange minimum)meta.rewardDirty.reason: "episode_reopened",reopenedFor: "follow_up"recoveryReason: "dirty_reward_rescore",closeReason: "finalized",rTask = 0.0json_type(meta_json,'$.rewardDirty')still returnsobject17 days later:{"reason":"episode_reopened","reopenedFor":"follow_up","at":<ts>,"previousRTask":0,"previousScoredAt":<ts>}Manual
json_remove(meta_json,'$.rewardDirty')clears every symptom immediately. Note thatjson_set(meta_json,'$.rewardDirty',NULL)does not —json_typethen returns'null', which is stillIS NOT NULL, so any check written as a null-test keeps firing.How to Reproduce | 如何重现
Not yet reproduced deterministically; the above is a live record. The ordering that appears to be required:
core/reward/reward.ts:135).rTask != null || meta.reward→ re-sets the marker (core/session/episode-manager.ts:337). The skip-time clear has already run and will not run again.Expected: reaching a terminal/recovered state clears
rewardDirtyregardless of which path produced it.Actual: marker persists; the episode is reported dirty forever.
Relevant code (verified against
main,de806942)Set:
core/session/episode-manager.ts:337— on reopen, gated onsnap.rTask != null || snap.meta.rewardClear:
core/pipeline/memory-core.ts:1720— clears only inside the prior-failed-attempts rescan loop, and only for episodes that pass through it this passcore/pipeline/memory-core.ts:1757— clearsskipped && hasRewardDirtyMarkerrows, but over a bounded pagecore/reward/reward.ts:135and:276— clear at the moment a skip/terminal reward is writtenThe last three all clear at a specific moment or over a bounded page. Step 2 above re-dirties the episode after the moment-clear has passed, and the remaining two are the only later opportunities — so an episode that misses both keeps the marker permanently. Whichever of those two should have caught this row did not, which is the part worth confirming on the maintainer side; the durable fix is probably to drop the marker wherever the episode reaches a terminal reward state, rather than at each site that can write one.
Environment | 环境信息
algorithm.lightweightMemory.enabled: falsemain@de806942Additional Context | 其他信息
Impact: an operator-facing consistency check reports the row on every run forever. That trains the reader to ignore the check, which is the same class of observability decay that made a separate monitoring review misdiagnose frozen metrics.
Relevant prior issues reviewed: #1847, #1784, #2120, #1966 — all different failure modes (dirty-scan/skip-path fixes, abandoned-episode skip, dirty-closed pagination, ghost-trace over-scoring). I could not find an existing report of the marker surviving on a resolved episode.