Repository navigation
fix(ai): serialize agent runs per chat; idempotent same-day-safe create_event - #942
Merged
Merged
Conversation
This was referenced Nov 2, 2026
Two quick confirmations in one chat start parallel agent runs that repeat the same create_event
#929
Open
…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).
….ts and the engine digest it changes (Refs #929)
alex-mextner
force-pushed
the
fix/chat-serialization-20261101
branch
from
November 3, 2026 18:41
d7a6fb8 to
1abbe94
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #929, #520.
A second message in a chat now waits for the agent run ahead of it (per-chat FIFO gate),
create_eventis 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_EVENTquestion and could be read as its answer. Now the row keeps the time the user sent it (placement isturn_orderonly), 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 inask_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:
allow_duplicate/forceon a queued turn: the guarantee is the system prompt plus the guard (Enforce in the tool handlers that a queued message cannot confirm allow_duplicate, force or a duplicate create #957);source.shaof a dirty tree (the engine digest matches the gate), as the refactor(intent): split seed-calendar.ts under the file-length limit and re-measure the KPI gate (Refs #897) #904 re-measurement did.The per-chat queue depth cap and the per-run delivery outcome stay open in Two quick confirmations in one chat start parallel agent runs that repeat the same create_event #929. These limits are in the deploy runbook.
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 --noEmitand the KPI gate once on the build server. Full suite: hosted CI.Update (head d7a6fb8): the second commit after 17f022d formats
intent-executor.tsandchat-run-gate.test.ts(thetestjob'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-qais 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.tomlanduv.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 andstage-qaare green on this exact head. #942 ships before the run-outcome-alerts PR (#941), which then takes 0.3.2.