From 0f8bf94d2967711448417142a5f247d4fd40cb2c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 27 Apr 2026 14:43:16 -0400 Subject: [PATCH] fix: kickoff_post uses append_and_apply per-event (no full replay) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_ 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_ instead of 500ing. --- chat/web/kickoff.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/chat/web/kickoff.py b/chat/web/kickoff.py index 4359bb6..6d3db18 100644 --- a/chat/web/kickoff.py +++ b/chat/web/kickoff.py @@ -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)