Skip to content

Threads 16: Stop newly provisioned thread runs - #246

Open
FranciscoMoretti wants to merge 2 commits into
codex/threads-v2-12-first-message-barrierfrom
codex/threads-v2-13-stop-provisioning
Open

Threads 16: Stop newly provisioned thread runs#246
FranciscoMoretti wants to merge 2 commits into
codex/threads-v2-12-first-message-barrierfrom
codex/threads-v2-13-stop-provisioning

Conversation

@FranciscoMoretti

@FranciscoMoretti FranciscoMoretti commented Jul 16, 2026

Copy link
Copy Markdown
Owner

Summary

  • Makes server cancellation wait for a newly provisioned chat and reserved assistant row.
  • Scopes cancellation by both chat and message identity.
  • Preserves cancellation when stop races request preparation.

Behavior

Stop works during the provisioning window instead of being lost as a not-found race.

Verification

  • Browser verified an aborted GPT-5 mini run finalized with zero accumulated model cost while its GPT-5 nano sibling continued.

Review focus

Cancellation ownership, retry bounds, and database pressure of the short polling barrier.


Summary by cubic

Stops runs during new-thread provisioning and makes cancellations terminal to prevent not-found races and wasted tokens.

  • Bug Fixes

    • stopStream waits (≤30s) for the chat and target message, verifies ownership, scopes by { chatId, messageId }, finds the cancelable assistant via getCancelableResponseMessage (active or created after stop), and retries updateMessageCanceledAt until it succeeds.
    • Terminal cancellation: updateMessageCanceledAt clears activeStreamId, sets canceledAt, deletes parts in a transaction, and returns a boolean; updateMessage only updates uncanceled rows and returns a boolean; finalization skips and logs canceled assistant messages.
  • Migration

    • stopStream now requires chatId; multimodal-input sends { chatId, messageId }. Update remaining callers.

Written for commit 8f4949a. Summary will update on new commits.

Review in cubic

Stack

  1. Threads 0: Define the useThread target architecture #233
  2. Threads 22: Adopt AI SDK response identity lifecycle #258
  3. Threads 1: Define useThread package contracts #234
  4. Threads 2: Add the canonical message tree #235
  5. Threads 3: Adapt isolated AI SDK runs #236
  6. Threads 4: Orchestrate concurrent tree runs #237
  7. Threads 5: Prove AI SDK behavioral parity #238
  8. Threads 6: Expose the useChat-compatible useThread hook #239
  9. Threads 7: Rename the thread controller #261
  10. Threads 8: Add externally owned thread state #262
  11. Threads 9: Store ordered thread snapshots in ChatJS #240
  12. Threads 10: Mount useThread in ChatJS #241
  13. Threads 11: Add branch navigation and retry #242
  14. Threads 12: Isolate branch stream lifecycles #243
  15. Threads 13: Stream follow-up parallel responses #244
  16. Threads 14: Add cancellable request gates #263
  17. Threads 15: Gate first-message parallel runs #245
  18. Threads 16: Stop newly provisioned thread runs #246 👈 current
  19. Threads 17: Show parallel response lifecycle states #247
  20. Threads 18: Publish installable thread sources #248
  21. Threads 19: Add the thread playground model #249
  22. Threads 20: Add the interactive thread playground #250
  23. Threads 21: Publish the threads product page #251
  24. Threads 22: Add the value-first package guide #252
  25. Threads 23: Document ChatJS threaded behavior #253
  26. Threads 24: Prepare the thread package release #254

@cursor

cursor Bot commented Jul 16, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@vercel

vercel Bot commented Jul 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
chat-js-docs Ready Ready Preview, Comment Jul 27, 2026 6:10pm
chat-js-site Ready Ready Preview, Comment Jul 27, 2026 6:10pm
sparka Error Error Jul 27, 2026 6:10pm

Request Review

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @FranciscoMoretti, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c6dd7f13-aec6-439e-aed6-34360f143dc7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/threads-v2-13-stop-provisioning

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes stopStream race-safe during the provisioning window by polling for the chat, the user message, and the assistant response before recording cancellation, and makes cancellation terminal by transactionally clearing activeStreamId and deleting parts in updateMessageCanceledAt.

  • stopStream handler (chat.router.ts): three sequential polling loops — chat, user message, then assistant response — all share one 30-second deadline and one retryDelay counter; canceledAt is captured after the chat loop rather than at the moment the user clicked Stop.
  • updateMessage / updateMessageCanceledAt (queries.ts): updateMessage gains an isNull(canceledAt) guard and returns a boolean; updateMessageCanceledAt now runs a transaction that sets canceledAt, clears activeStreamId, and deletes parts — but its WHERE clause still lacks isNull(message.canceledAt), so it will re-cancel and re-delete parts for an already-canceled row.
  • finalizeMessageAndCredits (route.ts): logs when messageSaved is false (canceled message) but deductCredits still runs unconditionally on the line that follows, contradicting the "zero accumulated model cost" goal stated in the PR description.

Confidence Score: 3/5

Not safe to merge: the credits-deduction gap and the shared deadline/stoppedAt timing issues flagged in prior rounds remain unresolved in the current diff.

Three concrete correctness problems from earlier review rounds are still visible in the current code: (1) deductCredits runs unconditionally in finalizeMessageAndCredits even when messageSaved is false, so a user who stops a stream is still charged in full; (2) all three polling loops share a single 30-second deadline with a shared retryDelay, so a chat that takes time to provision consumes the budget meant for the message and response loops; (3) canceledAt is captured after the chat-provisioning loop rather than at click time, so fast models that finish during provisioning fall outside the getCancelableResponseMessage fallback window. The DB transaction and isNull(canceledAt) guard on updateMessage are solid improvements, and the client-side chatId forwarding is correct.

Files Needing Attention: apps/chat/trpc/routers/chat.router.ts (deadline sharing, stoppedAt timing) and apps/chat/app/(chat)/api/chat/route.ts (unconditional credit deduction after canceled finalization)

Important Files Changed

Filename Overview
apps/chat/trpc/routers/chat.router.ts stopStream handler rewritten to poll for provisioned chat, user message, and assistant response before canceling; shares a single 30-second deadline and retryDelay across all three phases, creating a race where a slow-provisioning chat leaves no time for the message loop, and the canceledAt timestamp is captured after the chat loop rather than at user-click time
apps/chat/lib/db/queries.ts updateMessage gains an isNull(canceledAt) guard and a boolean return; updateMessageCanceledAt now transactionally clears activeStreamId and deletes parts; new getCancelableResponseMessage function queries active or recently-created assistant responses, but the WHERE clause on updateMessageCanceledAt lacks isNull(canceledAt), allowing re-cancellation of already-canceled rows
apps/chat/app/(chat)/api/chat/route.ts Captures the boolean return from updateMessage and logs when finalization is skipped, but deductCredits still runs unconditionally below, so a canceled message still incurs the full token cost
apps/chat/components/multimodal-input.tsx handleStop now forwards chatId alongside messageId to satisfy the updated stopStream API contract; chatId added to the useCallback dependency array correctly

Sequence Diagram

sequenceDiagram
    participant U as User (Browser)
    participant MI as MultimodalInput
    participant TR as stopStream TRPC
    participant DB as Database

    U->>MI: Click Stop
    MI->>TR: "mutate({ chatId, messageId })"

    TR->>DB: getChatById(chatId)
    alt chat not yet provisioned
        loop up to 30s exp backoff
            DB-->>TR: null
            TR->>DB: getChatById(chatId)
        end
    end
    DB-->>TR: chat row

    TR->>TR: "capture canceledAt = new Date()"

    TR->>DB: getMessageById(messageId)
    alt message not yet provisioned
        loop shared deadline/retryDelay
            DB-->>TR: null
            TR->>DB: getMessageById(messageId)
        end
    end
    DB-->>TR: targetMessage

    alt "targetMessage.role === assistant"
        TR->>TR: "responseMessageId = messageId"
    else "targetMessage.role === user"
        loop shared deadline/retryDelay
            TR->>DB: getCancelableResponseMessage
            Note over DB: Q1 activeStreamId IS NOT NULL
            Note over DB: Q2 fallback createdAt >= stoppedAt
            DB-->>TR: response or null
        end
    end

    TR->>DB: updateMessageCanceledAt(responseMessageId)
    Note over DB: Tx: SET activeStreamId=null canceledAt=T
    Note over DB: DELETE parts WHERE messageId=?
    DB-->>TR: true
    TR-->>MI: success

    Note over MI: route.ts finalizeMessageAndCredits
    MI->>DB: updateMessage skipped if canceledAt set
    DB-->>MI: "messageSaved=false"
    MI->>DB: deductCredits runs unconditionally
Loading

Reviews (37): Last reviewed commit: "fix(chat): make response cancellation te..." | Re-trigger Greptile

Comment on lines +217 to +232
while (
!(await updateMessageCanceledAt({
messageId: input.messageId,
canceledAt,
}))
) {
if (Date.now() >= deadline) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Message not found",
});
}

await new Promise((resolve) => setTimeout(resolve, retryDelay));
retryDelay = Math.min(retryDelay * 2, 250);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Message ownership not verified against the authenticated chat

The new flow validates that input.chatId belongs to the current user, but updateMessageCanceledAt only filters by eq(message.id, messageId) — it never checks that the target message actually belongs to input.chatId. A user who owns any valid chat can therefore craft a request with their own chatId and a messageId from a different user's chat and successfully set canceledAt on that foreign message, effectively stopping another user's stream without permission. The fix is to add an eq(message.chatId, chatId) predicate to the updateMessageCanceledAt query (or validate msg.chatId === input.chatId before updating).

Comment on lines +215 to +217
const canceledAt = new Date();

while (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 retryDelay is shared across both polling loops, so the message loop starts at whatever value the chat loop left it at. If the chat loop ran several iterations and bumped retryDelay to 250 ms, the message polling loop starts at 250 ms instead of 25 ms. Resetting retryDelay between the two loops preserves the intended exponential-backoff shape for each phase.

Suggested change
const canceledAt = new Date();
while (
const canceledAt = new Date();
retryDelay = 25;
while (

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 26f76d0322

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +218 to +221
!(await updateMessageCanceledAt({
messageId: input.messageId,
canceledAt,
}))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scope cancellation to the authorized chat

When a caller supplies an owned chatId together with a message ID from another chat, the ownership check validates only the supplied chat, while this update still filters solely by messageId. The mutation can therefore set canceledAt on another user's active message if its ID is known; include the authorized chat ID in the update predicate or verify the message belongs to that chat.

Useful? React with 👍 / 👎.

Comment on lines +202 to +205
while (!chat && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, retryDelay));
retryDelay = Math.min(retryDelay * 2, 250);
chat = await getChatById({ id: input.chatId });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid polling arbitrary unprovisioned chat IDs

For any nonexistent UUID, every authenticated call now holds a request open for 30 seconds and performs roughly 120 database reads before returning NOT_FOUND; protectedProcedure only authenticates and adds no rate limit. A caller can cheaply amplify concurrent requests into sustained database and serverless-invocation load, so the provisioning race needs a bounded reservation mechanism that distinguishes legitimate pending chats rather than polling every supplied ID.

Useful? React with 👍 / 👎.

Comment on lines +218 to +221
!(await updateMessageCanceledAt({
messageId: input.messageId,
canceledAt,
}))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check persisted cancellation before starting the model

When this update wins the provisioning race and stores canceledAt before the chat request starts its model stream, the request does not read that value until the onChunk callback in api/chat/route.ts. The provider request is therefore still launched and can run or incur cost until its first chunk arrives, which can be significantly delayed for slow or reasoning-heavy models; check the persisted flag and abort before creating the model stream.

Useful? React with 👍 / 👎.

@FranciscoMoretti
FranciscoMoretti force-pushed the codex/threads-v2-13-stop-provisioning branch from 26f76d0 to dc8e43d Compare July 16, 2026 18:25
@FranciscoMoretti
FranciscoMoretti force-pushed the codex/threads-v2-12-first-message-barrier branch from f326077 to 6fc1076 Compare July 16, 2026 18:25
@FranciscoMoretti
FranciscoMoretti force-pushed the codex/threads-v2-13-stop-provisioning branch from f80e040 to 229aacd Compare July 20, 2026 18:53
@FranciscoMoretti
FranciscoMoretti force-pushed the codex/threads-v2-12-first-message-barrier branch from 074fa1e to 2d5586e Compare July 21, 2026 06:26
@FranciscoMoretti
FranciscoMoretti force-pushed the codex/threads-v2-13-stop-provisioning branch from 229aacd to 8a210bb Compare July 21, 2026 06:26
@FranciscoMoretti
FranciscoMoretti force-pushed the codex/threads-v2-12-first-message-barrier branch from 2d5586e to 56d64e3 Compare July 21, 2026 06:51
@FranciscoMoretti
FranciscoMoretti force-pushed the codex/threads-v2-13-stop-provisioning branch from 8a210bb to 38f495c Compare July 21, 2026 06:51
@FranciscoMoretti
FranciscoMoretti force-pushed the codex/threads-v2-12-first-message-barrier branch from 56d64e3 to 956dce5 Compare July 21, 2026 06:56
@FranciscoMoretti
FranciscoMoretti force-pushed the codex/threads-v2-13-stop-provisioning branch 2 times, most recently from 8fefd93 to b001092 Compare July 21, 2026 07:04
@FranciscoMoretti
FranciscoMoretti force-pushed the codex/threads-v2-12-first-message-barrier branch from 956dce5 to 5c9d63c Compare July 21, 2026 07:04
@FranciscoMoretti
FranciscoMoretti force-pushed the codex/threads-v2-13-stop-provisioning branch from b001092 to 7f878e8 Compare July 21, 2026 07:27
@FranciscoMoretti
FranciscoMoretti force-pushed the codex/threads-v2-12-first-message-barrier branch from 5c9d63c to 4c6230d Compare July 21, 2026 07:27
@FranciscoMoretti
FranciscoMoretti force-pushed the codex/threads-v2-12-first-message-barrier branch from 4c6230d to 69808d6 Compare July 23, 2026 06:47
@FranciscoMoretti
FranciscoMoretti force-pushed the codex/threads-v2-13-stop-provisioning branch from 7f878e8 to 4ebf788 Compare July 23, 2026 06:47
Comment on lines +1135 to +1144
const updatedMessages = await db.transaction(async (tx) => {
const updated = await tx
.update(message)
.set({ activeStreamId: null, canceledAt })
.where(eq(message.id, messageId))
.returning({ id: message.id });

if (updated.length > 0 && canceledAt !== null) {
await tx.delete(part).where(eq(part.messageId, messageId));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unconditional parts deletion can corrupt a fully-finalized message

updateMessageCanceledAt has no guard on isNull(message.canceledAt) and no check on whether the message was already finalized. If finalizeMessageAndCreditsupdateMessage commits parts (and clears activeStreamId) just before updateMessageCanceledAt runs, the transaction will match the existing row (since WHERE id = messageId always succeeds), delete the newly-saved parts, and return true. The user is charged the full model cost (since deductCredits executes regardless of messageSaved) but ends up with a canceled message that has no content.

The stop-stream flow lands here via the getCancelableResponseMessage fallback query (gte(createdAt, stoppedAt)), which matches already-finalized messages as long as they were created after stoppedAt. Since stoppedAt is recorded at the very start of the handler — before any DB work — any response message created while the stop request was in flight satisfies that predicate, including ones whose stream has already completed. Adding a guard such as isNotNull(message.activeStreamId) before deleting parts would limit destructive cancellation to messages that are still actively streaming.

Comment on lines +216 to +217
const canceledAt = new Date();
let [targetMessage] = await getMessageById({ id: input.messageId });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 canceledAt is captured after the chat-provisioning loop completes, not when the user clicks Stop. It is then passed as stoppedAt to getCancelableResponseMessage, which uses gte(message.createdAt, stoppedAt) as the fallback to catch responses that finished streaming before activeStreamId could be observed. If provisioning takes, say, 3 seconds and a fast model finishes within that window, the response message will have activeStreamId = null (stream done) and createdAt < canceledAt — both queries miss it, the loop exits with responseMessageId = null, and the stop is silently dropped. Moving the timestamp capture to the very top of the handler makes stoppedAt anchor to when the user actually clicked Stop and eliminates the gap.

Suggested change
const canceledAt = new Date();
let [targetMessage] = await getMessageById({ id: input.messageId });
let [targetMessage] = await getMessageById({ id: input.messageId });

Comment on lines +1135 to +1144
const updatedMessages = await db.transaction(async (tx) => {
const updated = await tx
.update(message)
.set({ activeStreamId: null, canceledAt })
.where(eq(message.id, messageId))
.returning({ id: message.id });

if (updated.length > 0 && canceledAt !== null) {
await tx.delete(part).where(eq(part.messageId, messageId));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 updateMessageCanceledAt sets canceledAt and deletes parts for any row matching only id, with no isNull(message.canceledAt) guard. If this function is called after updateMessage has already committed the finalized content (parts saved, activeStreamId cleared), the transaction will still match, wipe the parts, and mark the row canceled. The user is then charged full credits by deductCredits (since messageSaved was true in the finalization path) but ends up with a canceled, empty message. Adding isNull(message.canceledAt) to the WHERE clause makes the update a no-op on already-finalized messages and prevents this double-write corruption.

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