fix(history): 按 UTC instant 排序 Canonical Run History - #3347
Conversation
Signed-off-by: yuefengw <60574042+yuefengw@users.noreply.github.com>
Signed-off-by: yuefengw <60574042+yuefengw@users.noreply.github.com>
huangruiteng
left a comment
There was a problem hiding this comment.
Exact head: 4582acd98cdde0fdd1cccf349dc6fa5dad5420f3
动机
这个 PR 修复的是 Canonical Run History 的真实时间顺序错误。旧实现直接比较 generated_at 原始字符串;当两个合法 ISO-8601 时间使用不同 offset 时,字典序并不等于 UTC 时间轴顺序。受影响的不只是历史列表展示:collect_history() 排序后的结果还会进入 latest_status_run、agent semantic history,以及跨 Goal 的 top-level runs,所以错误排序可能让状态、语义上下文和 limit=1 查询选中较旧的 run。
PR 选择复用已有 control_plane.runtime.time.parse_timestamp(),没有引入第二套时间解析协议,也没有重写旧 index 或扩大持久化 schema。相较于只修某一个消费者,这个落点更合适:排序错误的共同根因就在 collect_history() 的 canonical read model,单点修复可以覆盖同一 Goal 和跨 Goal 两条路径。
改动思路
入口仍是 collect_history():它从每个 Goal 的 index.jsonl 读取并去重行,然后用新的 _chronology_key() 将 generated_at 解析为 UTC-aware datetime。排序键先区分“合法时间”和“缺失/畸形/UTC 转换溢出时间”,因此任何 legacy 坏值都不会压过合法 run;合法值再按 UTC instant 排序,并保留原始时间文本作为同一 instant 的确定性 tie-break。单 Goal 路径还保留原 index 位置作为最后 tie-break,跨 Goal 聚合则依赖已排序 Goal id 与 Python stable sort 保持确定性。
正向路径是:带 +08:00 的较早 run 与带 Z 的较晚 run 被 parse_timestamp() 归一化,collect_history() 将后者排在前面,latest_status_run、latest_runs_with_agent_context()、goal_semantic_history_from_runs() 和 top-level runs[:limit] 都消费同一正确顺序。负向路径是:缺失、无法解析或在 astimezone(UTC) 时溢出的时间返回 invalid key;它们仍保留在历史里,但落到所有合法时间之后,读取不会失败,也不会把坏行误当成 latest。
具体改动
loopx/history.py:新增 27 行、删除 3 行。生产行为集中在_chronology_key()以及collect_history()的两处排序调用;没有修改写入路径或外部 schema。tests/test_history_chronology.py:新增 148 行,建立最小 index fixture,覆盖同 Goal offset 顺序、跨 Goallimit=1、semantic history,以及缺失/畸形/overflow 时间。
关键代码讲解
_chronology_key(value):输入任意 legacygenerated_at,调用既有parse_timestamp()得到 UTC-aware 时间;OverflowError被收敛成 invalid,而不是让整个 history read model 失败。返回(validity, instant, raw),其中 validity 明确保证合法值优先,raw仅承担同 instant 或 invalid 集合内的确定性排序。collect_history()的 per-goal sort:在 chronology key 后追加原始 index 位置并倒序,保证相同 key 的重复/legacy 行仍有稳定顺序。这个排序先于latest_status_run、agent context retention 和 semantic history,所以三个消费者得到一致的 canonical chronology。collect_history()的all_runs.sort(...):跨 Goal 聚合复用同一个 helper,修复 top-levellimit以前仍按字符串排序的遗漏;没有复制第二套判断。parse_timestamp()(未改动的共享调用点):naive 时间继续按既有协议视为 UTC,带 offset 的时间转换为 UTC。PR 没有偷偷改变解析规则,只改变排序消费方式。
类型/状态规则方面,这里没有新增 substring denylist、散落布尔状态或领域词汇;合法/非法时间通过结构化排序键表达。行为变化在 PR 描述和回归测试中明确说明,属于 canonical read model 的 bug fix,不涉及 must_attempt_work 一类 guidance/obligation 契约。
对主干的风险
最强回归场景是 legacy 时间输入导致 history 命令异常,或同一 instant 的多种文本表示出现非确定顺序。实现通过捕获 UTC 转换溢出、给 invalid 值固定的 aware sentinel,并保留 raw/index tie-break 来封闭这两个风险。blast radius 是所有依赖 collect_history() 的状态、operator gate、summary、dreaming 和 CLI history 读取;好处是它们统一修复,风险也集中在一个 read-model helper,回滚只需恢复原排序逻辑且不会迁移数据。
我在 exact head 上独立运行了:
pytest -q tests/test_history_chronology.py:3 passed;run-history-readmodel-smoke.py:通过;run-history-agent-context-retention-smoke.py:通过;- Ruff(changed files):通过;
- 仓库标准
python -m mypykernel contracts:通过; - GitHub checks:7/7 成功。
测试同时包含正向 offset 归一化和负向 malformed/missing/overflow 路径。剩余风险主要是其他独立 run-index 读取器仍可能有自己的字符串排序;PR 明确将其列为 non-goal,因此不阻塞本 PR,但后续若发现同类 caller 应复用同一 typed helper,而不是再写局部字符串规则。
我的整体评价
结论:APPROVE。改动体量与问题匹配,生产代码只有一个可复用排序 helper 和两处真实调用点;测试虽然比实现长,但覆盖了 canonical history 的多个实际消费者与关键失败路径,不是一次性样例。未发现阻塞性正确性、兼容性、typed-state、domain-neutrality、行为披露或 guidance/obligation 问题。exact head 未发生变化,远端检查和独立 focused validation 均通过。
English verdict: APPROVE at exact head 4582acd98cdde0fdd1cccf349dc6fa5dad5420f3. The shared UTC-aware chronology key fixes both per-goal and cross-goal ordering, keeps malformed legacy rows readable but below valid timestamps, and passed focused chronology tests, two run-history smokes, Ruff, the repository-standard mypy kernel check, and all GitHub checks. No blocking finding.
变更概述
Closes #3346
collect_history()之前直接按generated_at原始字符串倒序。不同本地时区的 ISO-8601 offset 会导致字典序与真实时间轴不一致,从而选错latest_status_run、semantic history 或跨 Goal 的 top-level run。本 PR:
control_plane.runtime.time.parse_timestamp(),按 UTC instant 排序;all_runs;验证
3517 passed, 10 skipped全量 pytest;limit=1、semantic history、缺失/畸形/Overflow timestamp 回归测试;loopx canary premerge --from-git-diff通过:4/4 catalog canaries;兼容性
旧的 index 行不会被重写;无法解析的历史时间仍会被保留,但不会压过合法时间戳。