Repository navigation
feat(dmn): 热度图命令与调度可靠性修复 (0.9.0) - #9
Conversation
- rethinking/heatmap.py:Pillow 渲染 7×12 作息热度图,格内标累计计数、颜色按半衰期 折算到当前时刻,并标出当前时槽与下次运行窗口;缺中文字体时自动退回英文标签 - /memory heatmap(别名 heat、热图、热度图):隐藏子命令,鉴权同 /memory dmn, 出图失败退回文本状态,不影响查看 - 调度:misfire_grace_time 置空并显式 coalesce/max_instances,事件循环被卡住时 这次运行不再被静默丢弃 - 调度:待执行任务的触发时刻随状态落盘,重启后补排;停机期间错过的窗口 1 分钟内补跑 - 调度:单轮失败按 30 分钟间隔重试,最多 3 次,用户发消息会重置计数 - 依赖新增 pillow>=10.1
Reviewer's Guide本 PR 新增可脱离 NoneBot 渲染的 Sequence diagram for DMN schedule restoration and retrysequenceDiagram
participant User
participant Runner
participant StateStore
participant Scheduler
Runner->>StateStore: _load_state()
StateStore-->>Runner: pending_run_ts
Runner->>Runner: restore_schedule()
Runner->>Scheduler: _schedule_once(delay_seconds)
Scheduler-->>Runner: restored pending job
Scheduler->>Runner: _run()
Runner->>Runner: _run_cycle()
alt cycle succeeds
Runner->>StateStore: _save_state_locked()
else cycle fails
Runner->>Runner: _schedule_retry()
Runner->>Scheduler: _schedule_once(30 minutes)
end
User->>Runner: cancel_and_reschedule()
Runner->>Runner: reset _fail_streak
State diagram for reliable DMN executionstateDiagram-v2
[*] --> WaitingForUser
WaitingForUser --> Scheduled: cancel_and_reschedule()
Scheduled --> Running: _run()
Running --> WaitingForUser: successful cycle
Running --> RetryScheduled: cycle failure
RetryScheduled --> Running: _schedule_retry()
RetryScheduled --> WaitingForUser: 3 retries exhausted
Scheduled --> Scheduled: restore_schedule()
Running --> WaitingForUser: user message cancels cycle
Flow diagram for the memory heatmap commandflowchart TD
A["/memory heatmap"] --> B{管理员鉴权}
B -->|失败| C["未知选项"]
B -->|通过| D{subconscious.enabled}
D -->|否| E["返回调度未启用"]
D -->|是| F["获取 Runner 快照"]
F --> G["_heatmap_caption"]
F --> H["render_heatmap_png"]
H -->|成功| I["发送 PNG 图片"]
H -->|异常| J["记录错误并发送纯文本状态"]
I --> K["发送下次运行时间与窗口状态"]
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 12 issues
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="amrita_plugin_memory/rethinking/runner.py" line_range="588" />
<code_context>
_state.set_last_run_at(self._read_last_run_ts(payload))
self._planner.load(payload)
+ try:
+ self._pending_run_ts = float(payload.get("pending_run_ts", 0.0) or 0.0)
+ except (TypeError, ValueError):
+ self._pending_run_ts = 0.0
</code_context>
<issue_to_address>
**Pending run targets the wrong user**
When `target_user_id` changes while a pending run timestamp is stored, `_load_state()` restores the timestamp without checking its target user, and `restore_schedule()` schedules it for the current runner. The pending window from the previous target therefore runs DMN against the newly configured user.
Persist the target user associated with the pending timestamp and restore the timestamp only when that target matches the current `target_user_id`.
Also at `amrita_plugin_memory/rethinking/runner.py:292-296`.
</issue_to_address>
### Comment 2
<location path="amrita_plugin_memory/rethinking/runner.py" line_range="278" />
<code_context>
+ )
+ return
+ self._fail_streak += 1
+ self._schedule_once(_RETRY_DELAY_MINUTES * 60)
+ logger.warning(
+ f"[Subconscious] Retry #{self._fail_streak} "
</code_context>
<issue_to_address>
**Retries disappear after restart**
When the process restarts while a retry is pending or between consecutive failed runs, `_schedule_retry()` updates `_pending_run_ts` and `_fail_streak` in memory without saving them, and state loading does not restore the streak. `restore_schedule()` therefore loses the retry or uses a stale time, while the reset streak lets retries exceed the three-attempt limit; unsaved manual scheduling can also be restored from a stale timestamp.
Persist pending timestamps whenever retries or manual runs are scheduled, and include `_fail_streak` in saved and restored state.
Also at `amrita_plugin_memory/rethinking/runner.py:96-97`, `amrita_plugin_memory/rethinking/runner.py:334`, `amrita_plugin_memory/rethinking/runner.py:671`.
</issue_to_address>
### Comment 3
<location path="amrita_plugin_memory/rethinking/runner.py" line_range="278" />
<code_context>
+ )
+ return
+ self._fail_streak += 1
+ self._schedule_once(_RETRY_DELAY_MINUTES * 60)
+ logger.warning(
+ f"[Subconscious] Retry #{self._fail_streak} "
</code_context>
<issue_to_address>
**Retries duplicate completed writes**
When a cycle commits a memory write before a later operation fails, `_schedule_retry` starts a fresh full cycle after an exception, without checking which tool effects the failed cycle already committed. If a cycle writes a memory and then fails later, `subconscious_write_memory` creates a new UUID on retry, so the same note is stored as a duplicate.
Make mutating tool calls idempotent across cycle retries, or persist progress so retries skip already committed actions.
</issue_to_address>
### Comment 4
<location path="amrita_plugin_memory/rethinking/runner.py" line_range="417" />
<code_context>
logger.opt(exception=e, colors=True, raw=True).error(
f"[Subconscious] No usable preset, skip cycle: {e}"
)
+ self._cycle_failed = True
return
prompt_text = await self._load_prompt()
</code_context>
<issue_to_address>
**Failed runs clear accumulated backoff**
When the chat or agent raises after the cycle is considered valid, `_run_cycle` sets `_cycle_failed` but still calls `_post_process`, which resets the penalty based on cycle validity without checking the failure flag. The failed run therefore clears accumulated scheduling backoff.
Prevent `_post_process` from resetting the penalty when `_cycle_failed` is true.
</issue_to_address>
### Comment 5
<location path="amrita_plugin_memory/rethinking/heatmap.py" line_range="206" />
<code_context>
+ )
+ _right(draw, WIDTH - PAD, 40, planner.tz_name, f_sub, MUTED)
+
+ observed = round(sum(state.coarse))
+ tier_text = plan.tier if plan is not None else "-"
+ subtitle = _t(
</code_context>
<issue_to_address>
**Activity counts shrink over time**
When the planner has applied time decay to recorded observations, `render_heatmap_png()` displays sums of `state.coarse` and values from `state.fine` as observation counts, but `IdlePlanner._decay()` reduces those values over time. The subtitle and cell labels therefore show decayed weights instead of cumulative observations, making historical counts shrink or disappear.
Render cumulative observation counts rather than the decayed `state.coarse` and `state.fine` weights.
Also at `amrita_plugin_memory/rethinking/heatmap.py:244-247`.
</issue_to_address>
### Comment 6
<location path="amrita_plugin_memory/rethinking/runner.py" line_range="326" />
<code_context>
+ "pending_run_ts": self._pending_run_ts,
+ "is_running": self._is_running,
+ "penalty": _state.get_penalty_count(),
+ "plan": self._planner.last_plan(),
+ "tz_name": self._planner.tz_name,
+ }
</code_context>
<issue_to_address>
**Scheduled window details disappear**
When a run is scheduled through the cold-start fallback or a pending run is restored after restart, `scheduler_snapshot()` reads `IdlePlanner.last_plan()`, but `fallback_plan()` does not set that plan and planner state does not persist or restore it. The heatmap therefore omits the scheduled window details, including its duration and tier, despite a scheduled run.
Set the planner’s last plan for fallback schedules and persist and restore the plan used for pending runs.
Also at `amrita_plugin_memory/matchers.py:181`.
</issue_to_address>
### Comment 7
<location path="amrita_plugin_memory/rethinking/heatmap.py" line_range="90" />
<code_context>
+
+
+def _t(zh: str, en: str) -> str:
+ """有中文字体时用中文,否则退回英文,避免整张图变成豆腐块。"""
+ return zh if _font_path() else en
+
</code_context>
<issue_to_address>
**Invalid fonts produce missing glyphs**
When `AMRITA_MEMORY_HEATMAP_FONT` points to an existing file that Pillow cannot load, `_font_path()` treats the file as usable, while `_font()` falls back to Pillow’s default font. `_t()` still selects Chinese labels, which that fallback font cannot render, so the heatmap shows missing-glyph boxes instead of English labels.
Select Chinese labels only when the configured font loads successfully; otherwise use English labels.
Also at `amrita_plugin_memory/rethinking/heatmap.py:66-68`, `amrita_plugin_memory/rethinking/heatmap.py:91`.
</issue_to_address>
### Comment 8
<location path="amrita_plugin_memory/rethinking/heatmap.py" line_range="279" />
<code_context>
+ draw.text(
+ (PAD + 30 + w, panel_top + 40),
+ _humanize((local - now_local).total_seconds()),
+ font=f_sub,
+ fill=ACCENT,
+ )
</code_context>
<issue_to_address>
**DST countdown is off**
When the configured timezone observes DST and the next run crosses a DST transition, `local` and `now_local` use the same timezone, so their subtraction uses wall-clock time across a daylight-saving transition instead of elapsed time. The displayed relative countdown is therefore wrong by an hour when the scheduled run crosses a DST change.
Compute elapsed seconds from UTC timestamps before formatting the relative countdown.
</issue_to_address>
### Comment 9
<location path="amrita_plugin_memory/rethinking/heatmap.py" line_range="220" />
<code_context>
+ if state.last_update:
+ dt_days = max(0.0, (now_local.timestamp() - state.last_update) / 86400)
+ decay = 2 ** (-dt_days / planner.tau_fine) if dt_days else 1.0
+ weights = [max(0.0, v) * decay for v in state.fine]
+ peak = max(weights) if weights else 0.0
+
</code_context>
<issue_to_address>
**Heatmap colors never cool**
When there are no new observations for a period, `render_heatmap_png` multiplies every cell by the same decay factor and then divides each weight by the maximum. That common factor cancels, so the colors remain unchanged as the entire activity history ages without new messages.
Map the decayed weights to color using a stable absolute scale or otherwise retain the common decay factor when computing color intensity.
Also at `amrita_plugin_memory/rethinking/heatmap.py:238`.
</issue_to_address>
### Comment 10
<location path="amrita_plugin_memory/rethinking/heatmap.py" line_range="155" />
<code_context>
+ return f"in {minutes}m"
+
+
+def _plan_cells(plan: Plan, tz) -> set[tuple[int, int]]:
+ """把计划窗口铺到 (星期几, 槽位) 上,用于高亮。"""
+ cells: set[tuple[int, int]] = set()
</code_context>
<issue_to_address>
**Heatmap helpers lack complete types**
`_plan_cells` leaves `tz` untyped, while `_text_size`, `_center`, and `_right` leave `font` untyped, so callers can pass incompatible values without static type checking catching them.
Add type annotations for `tz` and each `font` parameter.
Also at `amrita_plugin_memory/rethinking/heatmap.py:111`, `amrita_plugin_memory/rethinking/heatmap.py:120`, `amrita_plugin_memory/rethinking/heatmap.py:132`.
</issue_to_address>
### Comment 11
<location path="amrita_plugin_memory/rethinking/heatmap.py" line_range="94" />
<code_context>
+ return zh if _font_path() else en
+
+
+def _ramp(v: float) -> tuple[int, int, int]:
+ v = max(0.0, min(1.0, v))
+ for i in range(len(_RAMP) - 1):
</code_context>
<issue_to_address>
**Heatmap helper contracts undocumented**
`_ramp`, `_text_size`, `_center`, `_right`, `_humanize`, and `_font` have no docstrings describing their parameters or side effects, leaving these new functions’ contracts undocumented for maintainers.
Add concise Chinese docstrings that describe each function’s parameter semantics and side effects.
Also at `amrita_plugin_memory/rethinking/heatmap.py:76`, `amrita_plugin_memory/rethinking/heatmap.py:111`, `amrita_plugin_memory/rethinking/heatmap.py:115`, `amrita_plugin_memory/rethinking/heatmap.py:128`, `amrita_plugin_memory/rethinking/heatmap.py:139`.
</issue_to_address>
### Comment 12
<location path="amrita_plugin_memory/rethinking/heatmap.py" line_range="1-9" />
<code_context>
+"""热度图渲染:把 IdlePlanner 的 7×12 作息热度画成 PNG。
+
+纯渲染模块,只依赖 Pillow 与 schedule 的数据结构,不碰 nonebot,
+方便脱离运行时用临时脚本出图核对。格内数字是落盘时点的累计计数,
+颜色再按半衰期折算到当前时刻——数字看"来过多少条",颜色看"现在有多热"。
+
+字体按候选路径查找,找不到中文字体时自动退回英文标签(PIL 自带位图字体
+没有 CJK 字形,硬画会出一片豆腐块)。
+"""
+
+from __future__ import annotations
</code_context>
<issue_to_address>
**新增说明不是单行短句**
热度图模块说明、渲染函数说明和恢复调度说明都跨多行,违反仓库对注释与 docstring 的单行约束,维护者看到的说明不符合既定格式。
将这些说明改为符合约束的中文单行短句。
Also at `amrita_plugin_memory/rethinking/heatmap.py:179-182`, `amrita_plugin_memory/rethinking/runner.py:285-289`.
</issue_to_address>Sourcery assessment
Needs a human reviewer. 8 findings to address first, and restoring missed schedules and retrying failed runs can execute additional DMN cycles, which may write changes to memory that remain after the code is reverted. The extra runs are bounded, but their persisted results would need to be reviewed or repaired separately.
Blocking findings: amrita_plugin_memory/rethinking/runner.py:588, amrita_plugin_memory/rethinking/runner.py:278, amrita_plugin_memory/rethinking/runner.py:278, amrita_plugin_memory/rethinking/runner.py:417, amrita_plugin_memory/rethinking/heatmap.py:206, and 3 more
- 版式转置:12 行两小时槽纵向排列,7 列星期横向展开,更接近作息表读法 - 色阶由平方根改线性:低计数不再被抬成和热点同亮度 - 规划窗口改画半透明覆盖层,按真实分钟定位,跨午夜/跨天会拆到对应列, 结束在槽中间时边界就落在槽中间 - 画布收成 760×814,去掉底部空白
画布与字号一起按 SCALE=2 放大后出图(760×814 → 1520×1628), 左下角图例与面板小字在 1x 下笔画粘连,超采样后边缘立得住。 同时把图例条加厚到 10 逻辑像素,小字统一提到 13 逻辑像素。
框的内缩量原来比格子少 1px,右边线落在两格之间的缝上、贴着相邻槽, 看着像没对齐。统一成和格子相同的 3px 内缩;上下边正好压在槽边界时 也向格内收一点,落在槽中间的(如 03:00)保持原位。
上一版按真实分钟画框,03:00 的顶边落在 02-04 槽中间,看着像框没对齐格子。 改成按槽取整:整块高亮从 02:00 到 10:00 框住四个槽,精确起止仍写在面板。 填色也改成逐格铺,不再把槽之间的缝一起染上。
红点原来贴着格子右下圆角,一半压在边线上,看着像漂到格子外面。 改成离边 9 个逻辑像素、半径 3.5,四边留白一致;图例补一句红点含义。
调度 - 待执行任务记录 pending_uid,target_user_id 变更后不再拿旧窗口去跑新用户 - 重试排期与失败计数落盘(fail_streak),重启后重试不会丢、也不会超过三次 - 手动触发清空 pending 后立即落盘,避免重启恢复出旧计划 - 周期失败时不再重置惩罚,失败不会被当成一轮有效整理 热度图 - 新增 raw_fine/raw_coarse 累计计数(不参与衰减),格内数字与观测总数不再随时间缩水 - 颜色分母改用累计峰值,整张图会随时间一起变淡,而不是永远拉满 - 字体候选改为实际试加载,指到损坏文件时退回英文标签而不是方框 - 相对时间用时间戳相减,跨夏令时不再差一小时 - 上次规划随状态落盘,重启后仍能看到待执行窗口的时长与分层;fallback 也记规划 - 补齐 helper 的类型注解与中文 docstring
改动
1.
/memory heatmap热度图命令rethinking/heatmap.py:Pillow 渲染 7×12 作息热度图(纯渲染,不依赖 nonebot,可脱离运行时出图核对)。AMRITA_MEMORY_HEATMAP_FONT指定字体),不会出豆腐块。heat/热图/热度图,隐藏且不进菜单,鉴权与/memory dmn一致(超级用户 →lp.admin)。2. 调度可靠性(审下来会「静默不跑」的几处)
misfire_grace_time=30coalesce/max_instances=1pending_run_ts),启动时restore_schedule()补排;停机错过的窗口 1 分钟内补跑_cycle_failed标记,失败不重置_fail_streak刻意没有加定时自转:没有待执行任务时依旧等用户消息触发,避免无人使用时 DMN 无限跑烧 token。
验证
uv run ruff check/uv run ruff format --check通过pyright --pythonpath .venv/bin/python amrita_plugin_memory0 errorpy_compile通过周六 03:00-07:45 (285min, tier=mid, relax=0),PNG 46KB;无待执行任务路径、缺字体回退路径均可正常出图(英文标签)AsyncIOScheduler复现「run_date 已过 5 分钟」,旧配置会被丢弃,misfire_grace_time=None下正常补跑Summary by Sourcery
Add a DMN activity heatmap and make scheduled memory consolidation resilient across delays, restarts, and transient failures.
New Features:
/memory heatmapcommand and aliases to render a 7×12 activity heatmap with current status and upcoming DMN schedule details.Bug Fixes:
Enhancements:
Build:
Documentation: