Skip to content

Restart during in-flight reflection scoring orphans finalized episodes (rTask NULL, never re-scored) #2355

Description

@chiefmojo

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:

  1. A chitchat episode (ep_7fw8xfeb4skp, 7 traces) — finalized, rTask = NULL, no stamp.
  2. 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:

  1. 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).
  2. 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

  1. Start a task-intent conversation with enough turns (≥ 2 QA rounds) to trigger reflection scoring.
  2. Restart the bridge daemon after capture.reflect.scoring.start is logged but before capture.reflect.done.
  3. 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).

Activity

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

Metadata

Metadata

Labels

area:pluginOpenClaw & Hermesstatus:needs-triageNeeds initial triage | 需要初步判断 & 问题复现

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions