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-plugin v2.0.16
algorithm.lightweightMemory.enabled: false
autoRecovery at default (enabled)
Observed behavior
Two independent occurrences in the same session on 2026-09-09:
- A
chitchat episode (ep_7fw8xfeb4skp, 7 traces) — finalized, rTask = NULL, no stamp.
- A
task episode (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 = NULL
trace_ids_json length > 0
closeReason = "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_dirty clause flags it (because recoveryReason != dirty_reward_rescore and rTask 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 == null as rescorable and actually re-run the reward pass (rather than only excluding stamped episodes).
- Or stamp first-pass-interrupted episodes with
DIRTY_REWARD_RESCORE at 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.start is logged but before capture.reflect.done.
- Observe: the episode finalizes with
rTask = NULL and no recoveryReason, then trips Will trigger rescore (combined): 1 on 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).
Summary
When the bridge daemon is restarted (or the scoring watchdog interrupts) during in-flight reflection scoring, the affected episode is left
finalizedwithrTask = NULLand norecoveryReasonstamp. It then lingers in a half-recovered state that the consistency watchdog flags aswill_be_dirtyevery tick, until a human hand-patchesr_taskin the database.Environment
memos-local-pluginv2.0.16algorithm.lightweightMemory.enabled: falseautoRecoveryat default (enabled)Observed behavior
Two independent occurrences in the same session on 2026-09-09:
chitchatepisode (ep_7fw8xfeb4skp, 7 traces) —finalized,rTask = NULL, no stamp.taskepisode (ep_c28q4aka3s7z, 12 traces) —finalized,rTask = NULL, no stamp.Both triggered the
Will trigger rescore (combined): 1consistency alert and did not self-heal; each was resolved by manually settingr_task = 0.0.Root cause
The reflection scoring pipeline is not restart-safe. Log evidence for
ep_c28q4aka3s7zshows the full interrupt sequence:The daemon was restarted after scoring began but before it wrote
rTask, leaving the episodefinalizedwith:rTask = NULLtrace_ids_jsonlength > 0closeReason = "finalized"recoveryReason= absent ← the cruxThe asymmetry in
episodeRewardIsDirty()The dirty-check guard at
core/pipeline/memory-core.ts(≈ lines 1941–1954) anticipates this exact race:The
DIRTY_REWARD_RESCOREstamp — 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() → truebranch with no clean path out:will_be_dirtyclause flags it (becauserecoveryReason != dirty_reward_rescoreandrTask IS NULL).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:
finalized+rTask == null+traces > 0+recoveryReason == nullas rescorable and actually re-run the reward pass (rather than only excluding stamped episodes).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 manualr_taskpatching.Reproduction
task-intent conversation with enough turns (≥ 2 QA rounds) to trigger reflection scoring.capture.reflect.scoring.startis logged but beforecapture.reflect.done.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).