Félix Malfait 9be2b21e51 fix(ai): gate chat thread totals on stream ownership to prevent usage under-counting (#22534)
Fixes a usage under-counting bug introduced by #22524 (progressive
assistant-message persistence), flagged by @cubic-dev-ai and confirmed
by @FelixMalfait.

## Root cause

#22524 gated the thread-totals update (tokens, credits,
`conversationSize`) on `assistantMessageExistedAtStart` — an existence
check on the deterministic `uuidv5(streamId)` message id captured at job
start. That was a sound idempotency signal *before* #22524, when the
message only ever existed if a prior run had completed and applied
totals.

Progressive checkpoints broke that assumption: a checkpoint creates the
message row ~2s into the stream, without applying totals. So if the
worker is SIGKILLed after a checkpoint but before `handleStreamFinish`,
and the job is re-delivered (BullMQ's stalled re-run — `aiStreamQueue`
has no `maxStalledCount: 0` yet, that's #22518 — or an admin
`retryJobs`), the re-run sees `assistantMessageExistedAtStart === true`
and returns before the totals update. The turn's usage is lost
permanently. cubic's P2 (the non-transactional `delete`+`insert` in
`upsertAssistantMessage`) is the same root cause: its partless window
only mattered because it tripped the same existence-based gate.

## Fix

Stop inferring "totals already applied" from message existence. Gate the
totals update on **still owning the stream** — a conditional `UPDATE ...
WHERE id = :threadId AND activeStreamId = :streamId`, and only
`notifyThreadUsageUpdated` when it affects a row. This is the same claim
pattern the stream already uses (#22481), and it's idempotent by
construction:

- The run that completes while holding the claim → `affected = 1` →
totals applied exactly once. This holds **even when a checkpoint already
created the message**, which is precisely the bug.
- A duplicate/zombie run after another run completed (and its `finally`
cleared `activeStreamId`) → `affected = 0` → skipped, no double-count.
- A superseded run whose thread has moved to a newer stream → `affected
= 0` → skipped (defense-in-depth, aligns with #22518's ownership
pre-check).

The `assistantMessageExistedAtStart` flag and its start-of-stream
`hasMessageById` query are removed entirely — the message write is
already idempotent via the deterministic id + `upsert`, so it needs no
gate.

This subsumes cubic's P2: the totals are no longer lost regardless of
the `delete`+`insert` window, so no transaction is required for
correctness (the residual window is a benign sub-millisecond transient
for an actively-streaming message; happy to add a workspace-datasource
transaction as separate hardening if you'd prefer).

## Validation

`stream-agent-chat.job.spec.ts` (9 green):
- New: totals update returns `affected: 0` → `notifyThreadUsageUpdated`
**not** called (prior completion not double-counted), message still
upserted.
- New: message already exists from a checkpoint but claim still held
(`affected: 1`) → totals **are** applied — the exact regression #22524
caused.
- Existing success/error/cancel/abort flows updated for the conditional
criteria and still green.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22534?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 18:50:10 +02:00
2026-06-11 11:02:28 +02:00

Twenty logo

The #1 Open-Source CRM

Website · Documentation · Roadmap · Discord · Figma

Twenty banner


Why Twenty

Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves. Twenty is the CRM you build, ship, and version like the rest of your stack.

Learn more about why we built Twenty


Installation

Cloud

The fastest way to get started. Sign up at twenty.com and spin up a workspace in under a minute, with no infrastructure to manage and always up to date.

Build an app

Scaffold a new app with the Twenty CLI:

npx create-twenty-app my-app

Define objects, fields, and views as code:

import { defineObject, FieldType } from 'twenty-sdk/define';

export default defineObject({
  nameSingular: 'deal',
  namePlural: 'deals',
  labelSingular: 'Deal',
  labelPlural: 'Deals',
  fields: [
    { name: 'name', label: 'Name', type: FieldType.TEXT },
    { name: 'amount', label: 'Amount', type: FieldType.CURRENCY },
    { name: 'closeDate', label: 'Close Date', type: FieldType.DATE_TIME },
  ],
});

Then ship it to your workspace:

npx twenty app:publish --private

See the app development guide for objects, views, agents, and logic functions.

Self-hosting

Run Twenty on your own infrastructure with Docker Compose, or contribute locally via the local setup guide.



Everything you need

Twenty gives you the building blocks of a modern CRM (objects, views, workflows, and agents) and lets you extend them as code. Here's a tour of what's in the box.

Want to go deeper? Read the User Guide for product walkthroughs, or the Documentation for developer reference.

Create your apps

Learn more about apps in doc

Stay on top with version control

Learn more about version control in doc

All the tools you need to build anything

Learn more about primitives in doc

Customize your layouts

Learn more about layouts in doc

AI agents and chats

Learn more about AI in doc

Plus all the tools of a good CRM

Learn more about CRM features in doc


Stack

Thanks

Greptile      Sentry      Crowdin

Thanks to these amazing services that we use and recommend for code review (Greptile), catching bugs (Sentry) and translating (Crowdin).

Join the Community

Star the repo · Discord · Feature requests · Releases · X · LinkedIn · Crowdin · Contribute

S
Description
The open alternative to Salesforce, designed for AI.
Readme AGPL-3.0 1.4 GiB
Languages
TypeScript 79.6%
MDX 17.3%
JavaScript 2.7%
Python 0.2%
SCSS 0.1%