feat: regenerate rolls back lifecycle transitions on supersede (T114.3)

Closes the T83.4 gap: when ``regenerate_assistant_turn`` supersedes an
assistant_turn that already produced lifecycle transitions, it now
emits an ``event_status_reverted`` (T114.2) for each transition tagged
with ``triggered_by_assistant_turn_id == original_assistant_event_id``
(T114.1 back-reference) before the regenerated narrative is
reclassified.

Mapping from forward kind to ``prior_status`` lives in
``_PRIOR_STATUS_MAP``:
  - event_started   → planned
  - event_completed → active
  - event_cancelled → active (best-effort default; cancellation can fire
    from either planned or active, but detect_event_transitions only
    surfaces currently-active rows so 'active' is the realistic prior)

Backward compatibility: lifecycle rows authored before T114.1 lack the
back-reference field. Those are skipped (DEBUG log per row) and
collected into a legacy WARNING that preserves the T83.4
observability contract — operators still see un-rolled-back
transitions, just from older logs.

The classify-and-emit pass below the rollback now operates against an
events projection that has already been reverted, so re-firing
``event_started``/``event_completed``/``event_cancelled`` for the
regenerated narrative is safe — no double-emit of promotion artifacts.

Spec tests:
- ``test_regenerate_rolls_back_event_started_from_superseded_turn``
- ``test_regenerate_rolls_back_event_completed_to_active`` (also
  exercises the multi-rollback loop: a turn that fired both a start
  and a completion gets two event_status_reverted rows in id order,
  with active as the final projection — matching the per-row replay
  semantics of the projector)
- ``test_regenerate_skips_events_without_back_reference`` (pins the
  legacy compatibility path with both DEBUG and WARNING expectations)
This commit is contained in:
Joseph Doherty
2026-04-27 06:45:43 -04:00
parent 6d4ad86e33
commit 80ce891bd8
2 changed files with 459 additions and 36 deletions
+116 -36
View File
@@ -95,6 +95,27 @@ from chat.web.render import render_turn_html
_log = logging.getLogger(__name__)
# T114.3: map a lifecycle-transition event kind to the events-table
# status it implicitly transitioned *from*. Regenerate uses this to pick
# the ``prior_status`` value for the ``event_status_reverted`` rollback
# event so the projector sets the row back to where it was before the
# superseded turn fired the transition.
#
# - ``event_started`` was emitted when the row was 'planned' → revert to
# 'planned'.
# - ``event_completed`` was emitted when the row was 'active' → revert
# to 'active'.
# - ``event_cancelled`` could have fired from either 'planned' or
# 'active'. Best-effort default: 'active'. The forward transitions
# below only fire detect_event_transitions for currently-active rows,
# so 'active' is the realistic prior in practice.
_PRIOR_STATUS_MAP: dict[str, str] = {
"event_started": "planned",
"event_completed": "active",
"event_cancelled": "active",
}
async def regenerate_assistant_turn(
conn: Connection,
client,
@@ -115,17 +136,18 @@ async def regenerate_assistant_turn(
cannot be found — the FastAPI route translates this to 404.
.. note::
**Lifecycle-rollback limitation (T83.4, Phase 4 follow-up).**
**Lifecycle rollback (T114, Phase 4.5).**
When the superseded turn already produced lifecycle transitions
(``event_started`` / ``event_completed`` / ``event_cancelled``),
this function does NOT roll those rows back before re-running
``detect_event_transitions`` against the regenerated text. A
regenerate-after-completion can therefore double-emit promotion
artifacts if the new text re-completes the same event. Phase 3.5
only documents the gap and emits a WARNING log naming the
affected event_log ids; the actual undo pass is invasive
(re-projection / inverse-handler dispatch) and is deferred to
Phase 4. See the ``# T83.4`` block below for the warning emit.
this function emits an ``event_status_reverted`` event for each
so the events row's status returns to its prior value before the
regenerated narrative is reclassified. Backward compatibility:
lifecycle events authored before T114.1 lack the
``triggered_by_assistant_turn_id`` payload field; rollback skips
those (logged at DEBUG) so historic rows are not retroactively
reverted. A WARNING about un-rolled-back transitions is still
emitted when stragglers are found — the rollback handles the
common case while older logs continue to need manual review.
"""
chat = get_chat(conn, chat_id)
if chat is None:
@@ -158,20 +180,21 @@ async def regenerate_assistant_turn(
original_assistant_payload = json.loads(row[0])
original_user_turn_id = original_assistant_payload.get("user_turn_id")
# T83.4: scan for downstream lifecycle transitions emitted by the
# superseded turn — they're not being rolled back (see method
# docstring). Heuristic: any ``event_started`` / ``event_completed``
# / ``event_cancelled`` event_log row with id strictly greater than
# the original assistant_turn's id was emitted as part of (or after)
# that turn's processing. Lifecycle events don't carry ``chat_id``
# in their payload (their payload references an ``event_id`` FK to
# the ``events`` table, which holds chat_id), so we join through
# ``events`` to scope to this chat.
#
# A WARNING log surfaces the affected event ids so operators can
# spot double-emit cases until the Phase 4 rollback pass lands.
# T114.3: roll back lifecycle transitions emitted by the superseded
# turn. The scan uses the same id-greater-than-superseded-turn
# heuristic as the legacy T83.4 warning, joined to ``events`` for
# chat scoping (lifecycle events don't carry chat_id in their
# payload — they reference an ``event_id`` FK to the ``events``
# table, which holds chat_id). For each row whose payload carries
# ``triggered_by_assistant_turn_id == original_assistant_event_id``
# (T114.1 back-reference), emit an ``event_status_reverted`` event
# so the events-row status returns to the pre-transition value.
# Lifecycle rows authored before T114.1 lack the back-reference;
# those are skipped (DEBUG log) and a WARNING tracks their count so
# operators still see legacy stragglers — preserves the T83.4
# observability contract for un-rolled-back transitions.
unrolled_lifecycle = conn.execute(
"SELECT el.id, el.kind FROM event_log AS el "
"SELECT el.id, el.kind, el.payload_json FROM event_log AS el "
"JOIN events AS ev "
" ON ev.event_id = json_extract(el.payload_json, '$.event_id') "
"WHERE el.kind IN ("
@@ -182,18 +205,73 @@ async def regenerate_assistant_turn(
"ORDER BY el.id ASC",
(chat_id, original_assistant_event_id),
).fetchall()
if unrolled_lifecycle:
# T90.2: phrased as "at-or-after turn <id>" rather than "from
# superseded turn" because regenerating an OLDER turn lists
# intervening-turn transitions that legitimately stand on their
# own — those weren't authored by the superseded turn itself.
rolled_back_ids: list[int] = []
skipped_no_backref: list[int] = []
for el_id, el_kind, el_payload_json in unrolled_lifecycle:
try:
lifecycle_payload = json.loads(el_payload_json)
except (TypeError, ValueError):
skipped_no_backref.append(el_id)
continue
triggered_by = lifecycle_payload.get("triggered_by_assistant_turn_id")
if triggered_by != original_assistant_event_id:
# Either a legacy row (no field) or a transition triggered
# by a *different* turn — leave it alone. DEBUG so the
# message is available under verbose logging without
# spamming the default WARNING channel.
_log.debug(
"regenerate_assistant_turn: skipping rollback for "
"lifecycle event_log id=%d (kind=%s) — no back-reference "
"or different turn (triggered_by=%r vs superseded=%d)",
el_id,
el_kind,
triggered_by,
original_assistant_event_id,
)
if triggered_by is None:
skipped_no_backref.append(el_id)
continue
prior_status = _PRIOR_STATUS_MAP.get(el_kind)
if prior_status is None:
# Defensive: the SQL filter already restricts to the three
# known kinds, but a future schema addition shouldn't crash
# the rollback path.
continue
target_event_id = lifecycle_payload.get("event_id")
if target_event_id is None:
continue
append_and_apply(
conn,
kind="event_status_reverted",
payload={
"event_id": target_event_id,
"prior_status": prior_status,
},
)
rolled_back_ids.append(el_id)
if rolled_back_ids:
_log.info(
"regenerate_assistant_turn: rolled back %d lifecycle "
"transition(s) triggered by superseded turn %s "
"(event_log ids: %s)",
len(rolled_back_ids),
original_assistant_event_id,
rolled_back_ids,
)
if skipped_no_backref:
# T83.4 (legacy) compatibility: still warn about stragglers
# without the back-reference so operators can spot pre-T114
# double-emit risks. Phrased as "at-or-after turn <id>" per
# T90.2 — older transitions may legitimately belong to other
# turns.
_log.warning(
"regenerate_assistant_turn: %d lifecycle transition(s) "
"at-or-after turn %s are NOT being rolled back (Phase 4 "
"follow-up). Affected event ids: %s",
len(unrolled_lifecycle),
"at-or-after turn %s are NOT being rolled back (no "
"triggered_by_assistant_turn_id back-reference). "
"Affected event ids: %s",
len(skipped_no_backref),
original_assistant_event_id,
[r[0] for r in unrolled_lifecycle],
skipped_no_backref,
)
# 1a. Look up any sibling interjection beat in the same turn group
@@ -716,11 +794,13 @@ async def regenerate_assistant_turn(
# runs inline after a completion so promotion artifacts land in the
# same regenerate path.
#
# T83.4 follow-up: when a regenerate replaces a turn that had
# already produced event transitions, those original transitions
# are NOT undone here (Phase 4 work). A WARNING log earlier in this
# function names the affected event_log ids — see the T83.4 block
# near the function entry.
# T114.3: original-turn transitions emitted before this regenerate
# ran were rolled back at the top of the function (see the
# ``# T114.3`` block) by appending ``event_status_reverted`` for
# each. The classify-and-emit pass below now operates against an
# ``events`` projection that has already been reverted, so it can
# safely re-fire transitions for the regenerated narrative without
# double-emitting promotion artifacts.
new_active_events = list_active_events(conn, chat_id)
if new_active_events:
lifecycle_decision = await detect_event_transitions(