Restart during in-flight reflection scoring orphans finalized episodes (rTask NULL, never re-scored)
Summary
When the bridge daemon is restarted (or the scoring watchdog interrupts) during in-flight reflection scoring, the affected episode is left finalized with rTask = NULL and no recoveryReason stamp. It then lingers in a half-recovered state that the consistency watchdog flags as will_be_dirty every tick, until a human hand-patches r_task in the database.
Environment
- Version:
memos-local-pluginv2.0.16 algorithm.lightweightMemory.enabled: falseautoRecoveryat default (enabled)
Observed behavior
Two independent occurrences in the same session on 2026-09-09:
- A
chitchatepisode (ep_7fw8xfeb4skp, 7 traces) —finalized,rTask = NULL, no stamp. - A
taskepisode (ep_c28q4aka3s7z, 12 traces) —finalized,rTask = NULL, no stamp.
Both triggered the Will trigger rescore (combined): 1 consistency alert and did not self-heal; each was resolved by manually setting r_task = 0.0.
Root cause
The reflection scoring pipeline is not restart-safe. Log evidence for ep_c28q4aka3s7z shows the full interrupt sequence:
capture.reflect.scoring.start episodeId="ep_c28q4aka3s7z" steps=12
capture.reflect.trace.scored episodeId="ep_c28q4aka3s7z" ... alpha=0.5 usable=true
→ (fresh daemon boot: llm init + sqlite.open, NO capture.reflect.done)The daemon was restarted after scoring began but before it wrote rTask, leaving the episode finalized with:
rTask = NULLtrace_ids_jsonlength > 0closeReason = "finalized"recoveryReason= absent ← the crux
The asymmetry in episodeRewardIsDirty()
The dirty-check guard at core/pipeline/memory-core.ts (≈ lines 1941–1954) anticipates this exact race:
if (
ep.rTask == null &&
(ep.traceIds?.length ?? 0) > 0 &&
// Episodes already attempted by a recovery path carry DIRTY_REWARD_RESCORE.
// Excluding them prevents a crash-respawn loop when the watchdog fires
// mid-scoring and leaves rTask null: without this guard the next startup
// would re-pick the episode via closeReason="finalized" indefinitely.
meta.recoveryReason !== RECOVERY_REASONS.DIRTY_REWARD_RESCORE &&
(meta.closeReason === "finalized" || ...)
) {
return true;
}The DIRTY_REWARD_RESCORE stamp — the thing that would move an interrupted episode out of the "should rescore" branch — is only written by the recovery path (recoverDirtyClosedEpisodes(), line ~1784), not by first-pass scoring.
Consequence: an interruption during first-pass scoring leaves no stamp. The episode then sits in the episodeRewardIsDirty() → true branch with no clean path out:
- The consistency watchdog's
will_be_dirtyclause flags it (becauserecoveryReason != dirty_reward_rescoreandrTask IS NULL). - The bridge's own dirty-rescan either never re-visits it promptly, or the episode falls into a state where the rescan and the watchdog disagree indefinitely.
The guard comment explicitly says the exclusion is meant to prevent a crash-respawn loop — but that protection only exists for episodes that already carry the stamp, i.e. episodes interrupted during a recovery pass. First-pass interruptions are unhandled.
Proposed fix direction
One of:
- On startup / periodic scan, treat
finalized+rTask == null+traces > 0+recoveryReason == nullas rescorable and actually re-run the reward pass (rather than only excluding stamped episodes). - Or stamp first-pass-interrupted episodes with
DIRTY_REWARD_RESCOREat close time (or on the next scan), so they consistently drop out of both the dirty-check and the watchdog — accepting that their reward is lost, but without the perpetual alert.
Either resolves the disagreement between the consistency watchdog and episodeRewardIsDirty() that currently requires manual r_task patching.
Reproduction
- Start a
task-intent conversation with enough turns (≥ 2 QA rounds) to trigger reflection scoring. - Restart the bridge daemon after
capture.reflect.scoring.startis logged but beforecapture.reflect.done. - Observe: the episode finalizes with
rTask = NULLand norecoveryReason, then tripsWill trigger rescore (combined): 1on each consistency check without self-healing.
Relevant prior issues reviewed (#1782, #1966, #1841, #1452) all describe different failure modes (abandoned-episode skip, ghost-trace over-scoring, startup-hang).
Source: MemTensor/MemOS