From 7370f68bdfd031ae9bf27311c4a278bede025ccc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 27 Apr 2026 06:38:48 -0400 Subject: [PATCH] feat: lifecycle events carry triggered_by_assistant_turn_id back-reference (T114.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.5 T83.4 surfaced un-rolled-back lifecycle transitions on regenerate; T114 wires up the actual rollback. Step 1 is the back- reference: every event_started / event_completed / event_cancelled emitted by post_turn (chat/web/turns.py) and regenerate (chat/services/regenerate.py) now carries ``triggered_by_assistant_turn_id`` in its payload, set to the id of the assistant_turn event that produced the transition. Schema decision (Option A from the plan): no migration. The field is a payload convention only — older event_log rows lack it and rollback will skip them with a debug log when T114.3 lands. Forward-only. The post_turn lifecycle block already runs AFTER the assistant_turn event is appended (step 8a vs step 7), so ``primary_assistant_event_id`` is in scope. Same for regenerate: the lifecycle classification (step 9a) runs after step 6's append. **No emission-order reorder was needed** in either flow. Updates ``test_turn_with_event_transition_appends_started_event`` to assert the new field is present in the emitted event_started payload and points at the assistant_turn id. --- chat/services/regenerate.py | 14 ++++++++++++++ chat/web/turns.py | 16 ++++++++++++++++ tests/test_turn_flow.py | 12 ++++++++++++ 3 files changed, 42 insertions(+) diff --git a/chat/services/regenerate.py b/chat/services/regenerate.py index 6442bb2..bceaf16 100644 --- a/chat/services/regenerate.py +++ b/chat/services/regenerate.py @@ -738,6 +738,12 @@ async def regenerate_assistant_turn( payload={ "event_id": transition.event_id, "started_at": chat.get("time"), + # T114.1: back-reference to the assistant_turn + # that triggered this transition (see turns.py + # for rationale). + "triggered_by_assistant_turn_id": ( + new_assistant_event_id + ), }, ) elif transition.new_status == "completed": @@ -747,6 +753,10 @@ async def regenerate_assistant_turn( payload={ "event_id": transition.event_id, "completed_at": chat.get("time"), + # T114.1: back-reference (see above). + "triggered_by_assistant_turn_id": ( + new_assistant_event_id + ), }, ) promote_completed_event( @@ -762,6 +772,10 @@ async def regenerate_assistant_turn( payload={ "event_id": transition.event_id, "completed_at": chat.get("time"), + # T114.1: back-reference (see above). + "triggered_by_assistant_turn_id": ( + new_assistant_event_id + ), }, ) diff --git a/chat/web/turns.py b/chat/web/turns.py index dfb4b21..623390d 100644 --- a/chat/web/turns.py +++ b/chat/web/turns.py @@ -812,6 +812,14 @@ async def post_turn( payload={ "event_id": transition.event_id, "started_at": chat.get("time"), + # T114.1: back-reference to the assistant_turn that + # triggered this transition. Regenerate uses this + # to roll back lifecycle transitions when the turn + # is superseded. Forward-only — older events + # without this field are skipped by rollback. + "triggered_by_assistant_turn_id": ( + primary_assistant_event_id + ), }, ) elif transition.new_status == "completed": @@ -821,6 +829,10 @@ async def post_turn( payload={ "event_id": transition.event_id, "completed_at": chat.get("time"), + # T114.1: back-reference (see above). + "triggered_by_assistant_turn_id": ( + primary_assistant_event_id + ), }, ) # Run promotion inline so the artifact-emitting events @@ -842,6 +854,10 @@ async def post_turn( payload={ "event_id": transition.event_id, "completed_at": chat.get("time"), + # T114.1: back-reference (see above). + "triggered_by_assistant_turn_id": ( + primary_assistant_event_id + ), }, ) # Any other ``new_status`` value falls through silently — diff --git a/tests/test_turn_flow.py b/tests/test_turn_flow.py index 9d3fd0f..50209cb 100644 --- a/tests/test_turn_flow.py +++ b/tests/test_turn_flow.py @@ -1023,6 +1023,18 @@ def test_turn_with_event_transition_appends_started_event( assert started_payload["event_id"] == "evt_1" assert started_payload["started_at"] == "2026-04-26T20:00:00+00:00" + # T114.1: payload carries the back-reference to the assistant_turn + # that triggered the transition. The assistant_turn lands in + # event_log immediately before the event_started, so its id is + # the largest assistant_turn id in the chat at this point. + at_id = conn.execute( + "SELECT id FROM event_log " + "WHERE kind = 'assistant_turn' " + " AND json_extract(payload_json, '$.chat_id') = 'chat_bot_a' " + "ORDER BY id DESC LIMIT 1" + ).fetchone()[0] + assert started_payload["triggered_by_assistant_turn_id"] == at_id + # The events projection row reflects the active status. ev_row = conn.execute( "SELECT status, started_at FROM events WHERE event_id = ?",