fix: kickoff_post uses append_and_apply per-event (no full replay)

The route appended 6 events with append_event() and then called
project(conn) to apply them all at once. project() replays the
*entire* event log, but _apply_chat_created in chat/state/world.py
uses raw INSERT (not INSERT OR REPLACE), so the moment a second bot
got kicked off, projecting hit the existing chat_<bot_id> row from
the first kickoff and 500'd with sqlite3.IntegrityError: UNIQUE
constraint failed: chats.id.

Switch to append_and_apply (the live-path pattern in
chat/eventlog/log.py) which appends and applies only the new event
through its registered handler — leaving prior state untouched.
project() / append_event() imports are now unused in this file
and removed.

Suite: 470 passed in 11.8s. Verified manually: a second bot's
kickoff now redirects to /chats/chat_<bot_id> instead of 500ing.
This commit is contained in:
Joseph Doherty
2026-04-27 14:43:16 -04:00
parent 6d57fe88b4
commit 0f8bf94d29
+13 -13
View File
@@ -17,8 +17,7 @@ from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from chat.eventlog.log import append_event
from chat.eventlog.projector import project
from chat.eventlog.log import append_and_apply
from chat.llm.client import LLMClient
from chat.services.kickoff import parse_kickoff
from chat.state.entities import get_bot, get_you
@@ -263,8 +262,14 @@ async def kickoff_post(
).fetchone()
container_id = next_container_row[0]
# Use ``append_and_apply`` per event (live-path pattern) rather than
# appending all-then-project. ``project()`` replays the *entire*
# event log; non-idempotent handlers like ``_apply_chat_created``
# (raw INSERT into chats) then 500 with UNIQUE constraint failures
# for any chats that already exist from prior kickoffs.
# 1. chat_created
append_event(
append_and_apply(
conn,
kind="chat_created",
payload={
@@ -277,7 +282,7 @@ async def kickoff_post(
)
# 2. container_created
append_event(
append_and_apply(
conn,
kind="container_created",
payload={
@@ -293,7 +298,7 @@ async def kickoff_post(
bot_interruptible = bool(bot_activity_action_interruptible)
# 3. activity_change for "you"
append_event(
append_and_apply(
conn,
kind="activity_change",
payload={
@@ -314,7 +319,7 @@ async def kickoff_post(
)
# 4. activity_change for bot
append_event(
append_and_apply(
conn,
kind="activity_change",
payload={
@@ -335,7 +340,7 @@ async def kickoff_post(
)
# 5. scene_opened
append_event(
append_and_apply(
conn,
kind="scene_opened",
payload={
@@ -352,7 +357,7 @@ async def kickoff_post(
facts = _parse_facts(edge_seed_knowledge_facts)
if edge_seed_summary.strip():
facts.insert(0, f"[summary] {edge_seed_summary.strip()}")
append_event(
append_and_apply(
conn,
kind="edge_update",
payload={
@@ -363,9 +368,4 @@ async def kickoff_post(
},
)
# Project all events at once. ``bot_authored`` (already in log from prior
# POST) is idempotent (INSERT OR REPLACE); the new events project cleanly
# because they're being applied for the first time.
project(conn)
return RedirectResponse(url=f"/chats/{chat_id}", status_code=303)