feat: lifecycle events carry triggered_by_assistant_turn_id back-reference (T114.1)
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.
This commit is contained in:
@@ -738,6 +738,12 @@ async def regenerate_assistant_turn(
|
|||||||
payload={
|
payload={
|
||||||
"event_id": transition.event_id,
|
"event_id": transition.event_id,
|
||||||
"started_at": chat.get("time"),
|
"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":
|
elif transition.new_status == "completed":
|
||||||
@@ -747,6 +753,10 @@ async def regenerate_assistant_turn(
|
|||||||
payload={
|
payload={
|
||||||
"event_id": transition.event_id,
|
"event_id": transition.event_id,
|
||||||
"completed_at": chat.get("time"),
|
"completed_at": chat.get("time"),
|
||||||
|
# T114.1: back-reference (see above).
|
||||||
|
"triggered_by_assistant_turn_id": (
|
||||||
|
new_assistant_event_id
|
||||||
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
promote_completed_event(
|
promote_completed_event(
|
||||||
@@ -762,6 +772,10 @@ async def regenerate_assistant_turn(
|
|||||||
payload={
|
payload={
|
||||||
"event_id": transition.event_id,
|
"event_id": transition.event_id,
|
||||||
"completed_at": chat.get("time"),
|
"completed_at": chat.get("time"),
|
||||||
|
# T114.1: back-reference (see above).
|
||||||
|
"triggered_by_assistant_turn_id": (
|
||||||
|
new_assistant_event_id
|
||||||
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -812,6 +812,14 @@ async def post_turn(
|
|||||||
payload={
|
payload={
|
||||||
"event_id": transition.event_id,
|
"event_id": transition.event_id,
|
||||||
"started_at": chat.get("time"),
|
"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":
|
elif transition.new_status == "completed":
|
||||||
@@ -821,6 +829,10 @@ async def post_turn(
|
|||||||
payload={
|
payload={
|
||||||
"event_id": transition.event_id,
|
"event_id": transition.event_id,
|
||||||
"completed_at": chat.get("time"),
|
"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
|
# Run promotion inline so the artifact-emitting events
|
||||||
@@ -842,6 +854,10 @@ async def post_turn(
|
|||||||
payload={
|
payload={
|
||||||
"event_id": transition.event_id,
|
"event_id": transition.event_id,
|
||||||
"completed_at": chat.get("time"),
|
"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 —
|
# Any other ``new_status`` value falls through silently —
|
||||||
|
|||||||
@@ -1023,6 +1023,18 @@ def test_turn_with_event_transition_appends_started_event(
|
|||||||
assert started_payload["event_id"] == "evt_1"
|
assert started_payload["event_id"] == "evt_1"
|
||||||
assert started_payload["started_at"] == "2026-04-26T20:00:00+00:00"
|
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.
|
# The events projection row reflects the active status.
|
||||||
ev_row = conn.execute(
|
ev_row = conn.execute(
|
||||||
"SELECT status, started_at FROM events WHERE event_id = ?",
|
"SELECT status, started_at FROM events WHERE event_id = ?",
|
||||||
|
|||||||
Reference in New Issue
Block a user