From aa18f5eb9746092c81b01dd89c0e6eb464135851 Mon Sep 17 00:00:00 2001 From: rootkiller6788 Date: Thu, 20 Aug 2026 00:06:51 +0800 Subject: [PATCH] fix(event-sourced-state): preserve updated_at in todo-add projection _todo_from_added_event copies most persisted fields from the ADD event payload into the projected todo, but omitted updated_at. The producer (event_writeback.py) always writes updated_at into the ADD payload, and readers (render_todo_markdown, management_projection) read it back for last-activity display. Dropping it left the projection without the field until a later COMPLETED event happened to copy it. Add updated_at to the constructor dict using the same compact_text normalization the completion reducer already applies, and lock the behavior with a red->green regression test. Signed-off-by: rootkiller6788 --- .../bugs/event-sourced-updated-at-drop.md | 22 +++++++++++++++++++ loopx/event_sourced_state.py | 1 + .../test_todo_mutation_authority.py | 22 +++++++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 docs/development/bugs/event-sourced-updated-at-drop.md diff --git a/docs/development/bugs/event-sourced-updated-at-drop.md b/docs/development/bugs/event-sourced-updated-at-drop.md new file mode 100644 index 0000000000..7cadf3d68b --- /dev/null +++ b/docs/development/bugs/event-sourced-updated-at-drop.md @@ -0,0 +1,22 @@ +# Resolved: `_todo_from_added_event` 丢弃 `updated_at` + +**状态**:已修复(PR #3359,回归测试已转绿)。 + +## 问题 + +`_todo_from_added_event` 的构造字典漏拷贝 `updated_at`。生产端 `event_writeback.py` 在 +`TODO_ADDED` payload 中始终写入该字段,读取端 `render_todo_markdown` 与 +`management_projection` 又消费它;reducer 漏拷贝使新增 todo 投影丢失最后活动时间, +破坏「生产者写入 → 投影 → 消费者读取」的保真不变量。 + +## 修复 + +构造字典补入 `"updated_at": compact_text(payload.get("updated_at"))`,归一化与完成事件 +reducer 一致。缺省时 `compact_text(None)` 为空串,读取端 +`todo.get("updated_at") or todo.get("latest_event_at")` 的 fallback 不变。 + +## 回归测试 + +`test_updated_at_survives_todo_add_projection` +(`tests/control_plane/test_todo_mutation_authority.py`)—— 走真实 `TODO_ADDED` 事件 + +`build_state_projection`,断言投影保留时间戳。 diff --git a/loopx/event_sourced_state.py b/loopx/event_sourced_state.py index 6f28297c90..3bf43b3444 100644 --- a/loopx/event_sourced_state.py +++ b/loopx/event_sourced_state.py @@ -661,6 +661,7 @@ def _todo_from_added_event(event: dict[str, Any]) -> dict[str, Any]: "planner_order": payload.get("planner_order"), "append_sequence": event.get("append_sequence"), "last_event_id": event.get("event_id"), + "updated_at": compact_text(payload.get("updated_at")), } if task_class: todo["task_class"] = task_class diff --git a/tests/control_plane/test_todo_mutation_authority.py b/tests/control_plane/test_todo_mutation_authority.py index 76e676b39d..4cf90f994b 100644 --- a/tests/control_plane/test_todo_mutation_authority.py +++ b/tests/control_plane/test_todo_mutation_authority.py @@ -1311,6 +1311,28 @@ def test_task_domain_survives_markdown_event_projection() -> None: assert projection["agent_todos"]["items"][0]["task_domain"] == "validation" +def test_updated_at_survives_todo_add_projection() -> None: + added = make_state_event( + event_id="evt-updated-at-add", + goal_id=GOAL_ID, + event_type=TODO_ADDED, + refs={"todo_id": "todo_updated_at001"}, + payload={ + "role": "agent", + "title": "Validate one adaptive lane.", + "updated_at": "2026-07-18T00:00:00+00:00", + }, + recorded_at="2026-07-18T00:00:00+00:00", + ) + + projection = build_state_projection([added]) + + assert ( + projection["agent_todos"]["items"][0]["updated_at"] + == "2026-07-18T00:00:00+00:00" + ) + + def test_task_domain_event_update_is_normalized_and_invalid_values_fail() -> None: added = make_state_event( event_id="evt-domain-add",