Skip to content

fix(ai): serialize agent runs per chat; idempotent same-day-safe create_event - #942

Merged
alex-mextner merged 4 commits into
mainfrom
fix/chat-serialization-20261101
Nov 4, 2026
Merged

alex-mextner merged 4 commits into
mainfrom
fix/chat-serialization-20261101

Conversation

@alex-mextner

@alex-mextner alex-mextner commented Nov 2, 2026 •

Copy link
Copy Markdown
Owner

Refs #929, #520.

A second message in a chat now waits for the agent run ahead of it (per-chat FIFO gate), create_event is idempotent and same-day safe in one transaction, a queued turn is read after the earlier run's rows without changing its id (chat_history.turn_order, migration 069), and the intent path reports "already in the calendar" for a repeat.

Second commit (17f022d): the HIGH finding of the earlier review is fixed. A queued «Да» was placed after the earlier run's ask_user / SAME_DAY_EVENT question and could be read as its answer. Now the row keeps the time the user sent it (placement is turn_order only), the model is shown the turn as [queued: ...] (explained in the system prompt), and the day-reference guard ignores rows saved after the message. A regression test through the real pipeline and agent (first run ends in ask_user) failed before the fix. Also: the stale-snapshot refresh also runs when placement found newer rows, voice transcripts reserve their place in the chat line, a repeated series create is matched, and the hosted CI failures of the first commit are fixed (migration 069 doc, migration-order tests, tool catalog budget, handler fixtures, as never, KPI gate re-measured on the new engine digest).

Review: single-seat review (Opus only; Sol quota exhausted until 2026-11-08), two staged-diff reviews; the second one's findings are recorded, not hidden:

Verified: single test files locally (overlap, chat-history repository, day-reference guard, agent chat queue, duplicate create, message handler, voice, system prompt, payload budget and others), whole-repo tsc --noEmit and the KPI gate once on the build server. Full suite: hosted CI.

Update (head d7a6fb8): the second commit after 17f022d formats intent-executor.ts and chat-run-gate.test.ts (the test job's lint step failed on them) and re-measures the KPI activation gate, because formatting an engine file changes the engine digest. Hosted checks on this head: test, type-bans, lint-check, leftover-grep, review-threads, gitleaks, CodeQL, dependency-review and the stage image build pass. stage-qa is red with "could not run: not provisioned" (the QA Telegram login on the browser host cannot find the stage bot, #958): infrastructure, not a verdict on this PR; re-run the Stage QA workflow once it is fixed.

Update (head 705cd9f, 2026-11-04): the version-bump commit (0.3.0 -> 0.3.1, pyproject.toml and uv.lock) is added, reviewed by a single-seat review via omp Opus (claude-opus-5-5, three role invocations, no findings; the fixed Opus+Sol roster is refused until Sol returns 2026-11-09T10:20Z, the full-roster review is owed in #961). All hosted checks and stage-qa are green on this exact head. #942 ships before the run-outcome-alerts PR (#941), which then takes 0.3.2.

…nt and same-day safe (Refs #929, #520)

GramIO starts every update's handler without waiting for the previous one, so two quick
messages in one chat ran two agents on overlapping history (2026-11-01: every event was created
twice).

- ChatRunGate: CalendarBotAgent.run is FIFO per chat id, other chats parallel. A waiting message
  shows the typing indicator; a run ahead that outlives its lease (budget + 30 s) no longer blocks
  the chat and the user is told once (never for a scheduled run; every run that starts beside a
  still-unfinished one is told). Shutdown ends the wait and starts no lease timer.
- A message's place in its chat's line is reserved in the chat-logging middleware, in the same
  step that saves its row, so arrival order is row order even when a handler waits on a scene or
  retry lookup before the agent; the update's handling runs in that reservation (AsyncLocalStorage), so
  its agent run takes the place over, the update's end (or 60 s) releases an unused one. Turns queued behind a run are left out of its history and of get_history.
- A run that owns its message (took over a reservation; never a scheduled or supplement run) reads it
  after the earlier run's rows without changing its id (chat_history.turn_order, migration 069): the
  per-user chatHistoryIds map, action log and built contexts stay valid.
- create_event (EventRepository.createUnlessDuplicate, one immediate transaction):
  same calendar only (own events with no Google calendar / primary alias / the registered primary
  calendar id, owner_id, or the group; secondary-calendar imports never match), all-day values
  compared as floating dates with allDayDates in each event's own zone, and the event row and its
  reminders are written in the same transaction (also for allow_duplicate). Exact repeat (same
  title, start/end, stated details, 10 min) -> success with alreadyExists; same title on the same
  local day otherwise -> SAME_DAY_EVENT (not_applied, use update_event); allow_duplicate: true
  makes a deliberate copy.
- Intent answers: ToolResult.alreadyExists (success, mutationState not_applied) is carried by
  IntentExecutor into ToolAnswer, rendered as "already in the calendar", and replaces the closing
  respond of basis.event.create / create_invite, so they never say "created".
- Routing to AI agent, Agent run started and Agent run complete log updateId and messageId.

Note for the session rewriting write-outcomes.ts / write-receipt.ts (not edited here): a create_event
that found its event returns success: true with ToolResult.alreadyExists = { entity: 'event', id } and
no created-event evidence. The receipt fold must read result.alreadyExists (not the output prose) and
report "already in the calendar" with that id, not count it as a created write. A same-day conflict is
success: false, mutationState: not_applied, error starting SAME_DAY_EVENT.

Review: single-seat review (Opus only; Sol quota exhausted). Remaining medium/low findings are deferred in #929.
…t the answer to a question asked after it was sent (Refs #929, #520)

A user message that arrived while an earlier run of its chat was working is placed after that run's
rows. Before, its created_at was rewritten to the placement time and nothing told the model or the
day-reference guard that it predates those rows, so a queued «Да» read as the answer to an
ask_user / SAME_DAY_EVENT question the user had never seen, and the model could create a duplicate,
force a create or delete (the HIGH finding of the final review of db0c087).

- The row keeps the time the user sent it. Placement is only turn_order (newest row id + 0.5); the
  history readers order a row by the time of the row it was placed behind, then turn_order, then id,
  so reading order no longer depends on created_at ties.
- A placed turn is shown to the model with [queued: ...] after its timestamp (QUEUED_TURN_MARKER,
  described in the system prompt); the day-reference guard skips every row saved after the message
  when it looks for the question the message answers, for ask_user and plain-text questions. A
  button tap still binds to the question it was tapped under.
- A run whose earlier run finished just before it reached the gate refreshes the user and the
  schedule window too (placement found newer rows), not only a run that waited.
- Voice transcripts reserve their place in the chat line when the row is saved, run inside it and
  release it in a finally.
- create_event: a repeated create of a series (same title, start/end, rule, details within 10 min)
  returns the saved series; a series is never matched by same_day, and never against a one-off.
- Tests: overlap test through the real layer and agent whose first run ends in ask_user (red before:
  the queued «Yes» created the event), repository ordering/created_at, guard (ask_user, plain
  question, button), stale-snapshot gate test, series repeats, voice reservation. Fixes the
  db0c087 CI failures: migration 069 doc (migration gate), migration-order tests, tool catalog
  budget, handler fixtures (updateId/chatHistoryId), no `as never`, KPI gate re-measured on the new
  engine digest (docs/intents/kpi-gate.json plus two appended benchmark results).
- Runbook: queued-turn reading, once-per-hung-run lease notice, series matching.

Deferred with tickets: #952 (mixed create receipts), #953 (voice transcript sent-at), queue depth
cap and per-run delivery outcome stay open in #929.

Review: single-seat review (Opus only; Sol quota exhausted).
…ion and idempotent create_event fix (Refs #929, #520)

Reviewed as a single-seat review via omp Opus (claude-opus-5-5, three role invocations, no findings); the fixed Opus+Sol roster is refused until Sol returns 2026-11-09T10:20Z, the full-roster review stays owed in #961.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant