Skip to content

Threads 17: 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 17: 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 response cancellations terminal to prevent not-found races and wasted tokens.

  • Bug Fixes

    • stopStream waits (up to 30s) for the chat and target message, verifies ownership, scopes by {chatId, messageId}, resolves the response 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 logs and skips canceled assistant messages.
  • Migration

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

Written for commit 17dc851. 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 10: Store ordered thread snapshots in ChatJS #240
  11. Threads 8: Add externally owned thread state #262
  12. Threads 9: Align Thread with AI SDK request semantics #266
  13. Threads 10: Back threads with canonical Zustand state #267
  14. Threads 11: Mount useThread in ChatJS #241
  15. Threads 12: Add branch navigation and retry #242
  16. Threads 13: Isolate branch stream lifecycles #243
  17. Threads 14: Stream follow-up parallel responses #244
  18. Threads 15: Add cancellable request gates #263
  19. Threads 16: Gate first-message parallel runs #245
  20. Threads 17: Stop newly provisioned thread runs #246 👈 current
  21. Threads 18: Show parallel response lifecycle states #247
  22. Threads 19: Publish installable thread sources #248
  23. Threads 20: Add the thread playground model #249
  24. Threads 21: Add the interactive thread playground #250
  25. Threads 22: Publish the threads product page #251
  26. Threads 23: Add the value-first package guide #252
  27. Threads 24: Document ChatJS threaded behavior #253
  28. Threads 25: 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 Aug 11, 2026 7:40am
chat-js-site Ready Ready Preview Aug 11, 2026 7:40am
sparka Error Error Aug 11, 2026 7:40am

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: de1715c8-e6e6-41da-ab61-de419ba263d5

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
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