Skip to content

fix(ai): close pre-INSERT abort window + tighten abort mark predicates (#2028)#2062

Merged
2witstudios merged 5 commits into
masterfrom
fix/pre-insert-abort-window
Jul 14, 2026
Merged

fix(ai): close pre-INSERT abort window + tighten abort mark predicates (#2028)#2062
2witstudios merged 5 commits into
masterfrom
fix/pre-insert-abort-window

Conversation

@2witstudios

@2witstudios 2witstudios commented Jul 14, 2026

Copy link
Copy Markdown
Owner

Summary

Closes the cardinal Stop gap from #2028: a Stop pressed during the route's preflight window (0.5-3s TTFB) was a silent no-op that let the generation run to completion. Also fixes three smaller known issues (items 4a, 4b, 4c).

Does not touch items 2 (cross-instance multicast join) or 3 (takeover TOCTOU race) — those are separate changes with their own migration risk.

Item 1 — Pre-INSERT pending-abort intent 🔴

The bug. createStreamLifecycle runs at the END of the route's preflight — after auth, permissions, message persistence, and context assembly (0.5-3s). A Stop pressed during that window:

  • abortConversationStreams SELECTs status='streaming' rows → 0 rows
  • markAbortRequested UPDATEs by conversation → 0 rows marked
  • Returns not_found → UI stays SILENT by design
  • Generation starts a moment later and runs to completion: write tools, billing, the lot.

The fix. A durable pending-abort intent table (ai_pending_abort_intents), keyed by (conversation_id, user_id):

  • abortStreamAnywhere records a pending-abort intent when Stop finds nothing in flight
  • createStreamLifecycle consumes it right after the aiStreamSessions INSERT (registration + insert always happen first, so the same check also catches a Stop landing in the narrow gap between entering the function and the INSERT resolving — see Post-review convergence below). If present, the row is flipped to status='aborted', the registry entry is evicted, and the handle returns preAborted: true
  • Both routes (chat/route.ts, global/[id]/messages/route.ts) abort the controller and skip straight to onFinish when preAborted is true, so streamText never starts

Security. Same model as ai_stream_sessions.abort_requested_at. The composite PK (conversation_id, user_id) IS the authorization — a row can only be written by a Stop that has already authenticated as user_id, and only consumed by createStreamLifecycle, which receives userId from the authenticated route. No forgeable payload.

TTL. 30s. The preflight it targets is 0.5-3s. A stale intent is consumed but not honoured.

Item 4a — False unconfirmed when locally aborted

abortStreamAnywhere returned UNCONFIRMED when markAbortRequested failed, even if streams were demonstrably killed in-process (locallyAborted.length > 0). Now returns ABORTED in that case — a false "may still be running" about streams that are provably dead.

Item 4b — clearAbortMarks missing status predicate

Added status='streaming' to the clearAbortMarks UPDATE WHERE clause, so a mark written against a row that has since terminated (or been taken over) is not cleared.

Item 4c — Takeover reconcile missing abortRequestedAt: null

The inline reconcile in stream-takeover.ts now nulls abortRequestedAt (matching reconcileDeadStreamRows). Harmless today, but the two reconcile paths now look the same.

Changes

File Change
packages/db/src/schema/ai-streams.ts New aiPendingAbortIntents table definition
packages/db/drizzle/0206_pending_abort_intents.sql Migration: creates ai_pending_abort_intents
apps/web/src/lib/ai/core/pending-abort-intents.ts New. recordPendingAbort, consumePendingAbort, clearPendingAbort, sweepExpiredPendingAbortIntents
apps/web/src/lib/ai/core/stream-lifecycle.ts Consumes pending-abort right after INSERT; pre-aborts if present; preAborted on handle
apps/web/src/lib/ai/core/abort-stream-anywhere.ts Records pending-abort when nothing found; item 4a fix
apps/web/src/lib/ai/core/stream-abort-mark.ts Item 4b: status predicate on clearAbortMarks
apps/web/src/lib/ai/core/stream-takeover.ts Item 4c: null abortRequestedAt in reconcile
apps/web/src/app/api/ai/chat/route.ts Aborts controller and skips straight to onFinish when preAborted
apps/web/src/app/api/ai/global/[id]/messages/route.ts Same for global assistant
apps/web/src/app/api/cron/sweep-expired/route.ts Sweeps expired ai_pending_abort_intents rows alongside the other append-with-TTL tables

Tests

21 new/updated tests across 5 files. Full ai/core + chat + global + cron/sweep-expired test directories (1058 tests) pass; typecheck and lint clean.

  • pending-abort-intents.test.ts (11 tests): record/consume/clear with TTL, error swallowing, sweep
  • abort-stream-anywhere-pending.test.ts (6 tests): intent recording, item 4a fix, result-code assertion
  • stream-lifecycle.test.ts (48 tests): pre-aborted path — handle, registry eviction, no broadcast, INSERT-then-UPDATE, no-op finish/pushPart
  • cron/sweep-expired/route.test.ts: extended to cover the new sweep alongside the existing three

Invariants preserved

  • request.signal never reaches streamText (disconnect-immunity test unchanged)
  • No live foreign row is terminal-written
  • No path may abort or mark a stream the caller does not own (user_id predicate everywhere)
  • Stop must never silently do nothing while a generation is running, and must never falsely warn about one that stopped

Post-review convergence

Addressed automated review (Codex + CodeRabbit) and a follow-up self-review pass (8 independent finder agents: line-scan, removed-behavior audit, cross-file tracer, reuse, simplification, efficiency, altitude, CLAUDE.md conventions):

  • Consume/insert race (Codex P2, CodeRabbit Major): the original pre-INSERT-only check missed a Stop landing between the check and the INSERT resolving, and left an orphaned intent that would wrongly pre-abort the next send. Fixed, then simplified: rather than keeping two sequential checks (one before, one after the INSERT — 3 DB round-trips per stream start), collapsed to a single check placed after the INSERT. Nothing else consumes the intent in the interim, so one check there covers both timing cases at one fewer round-trip on this TTFB-sensitive hot path. The check's comment now discloses the still-open (narrow, single-digit-ms, TTL-bounded, self-healing) commit-ordering race between this consume and a concurrent recordPendingAbort, matching this codebase's own KNOWN RACE disclosure convention rather than implying full closure.
  • Wrong/misnamed Drizzle snapshot (Codex P2): the committed 0206 snapshot didn't chain from 0205 and didn't declare the new table. Regenerated correctly via drizzle-kit against a scratch out-folder, verified the SQL matches the already-committed migration, and renamed to match this repo's <idx>_snapshot.json convention.
  • Pre-aborted requests still built the full generation setup (CodeRabbit nitpick): both routes now return at the top of execute() when preAborted, skipping command-plan writes, model-capability resolution, and the agent loop — onFinish still runs unmodified so credit-hold release is untouched.
  • Periodic sweep for orphaned intents (CodeRabbit nitpick, 2x): added sweepExpiredPendingAbortIntents(), wired into the existing /api/cron/sweep-expired route (already running every 15 min) alongside the other append-with-TTL sweeps.
  • Test assertion gap (CodeRabbit nitpick): abort-stream-anywhere-pending.test.ts now asserts result.code for the "marked but finished here" branch, not just the side effect.

Closes #2028 (items 1, 4a, 4b, 4c).

Refs: #2022

Summary by CodeRabbit

  • New Features

    • Stop requests are now reliably honored even if submitted during the preflight window before a stream starts.
    • Streaming can be pre-aborted so generation won’t begin or produce output.
    • Abort intents are retained briefly when no active stream is found, then applied to the next matching generation.
    • Added a cron sweep for expired pending-abort intents.
  • Bug Fixes

    • Improved abort lifecycle handling to prevent late execution after pre-abort.
    • Fixed abort mark clearing and improved abort metadata cleanup during abort flows.
  • Tests

    • Added/extended coverage for pre-abort and race-condition behaviors.

#2028)

Item 1 — Pre-INSERT pending-abort intent:
- New `ai_pending_abort_intents` table + migration (0206)
- `pending-abort-intents.ts`: recordPendingAbort, consumePendingAbort, clearPendingAbort
- `abort-stream-anywhere.ts`: records a pending-abort intent when Stop finds nothing in flight
- `createStreamLifecycle`: consumes pending-abort at INSERT time; if present, writes the
  row as 'aborted' directly and returns a pre-finished handle (preAborted: true)
- Both chat routes (chat/route.ts, global/[id]/messages/route.ts): abort the controller
  when preAborted is true, so streamText never starts

Item 4a — False `unconfirmed` when locally aborted:
- `abort-stream-anywhere.ts`: if markAbortRequested fails but locallyAborted is non-empty,
  return ABORTED (streams are provably dead in-process) instead of UNCONFIRMED

Item 4b — clearAbortMarks missing status predicate:
- `stream-abort-mark.ts`: adds `status='streaming'` filter to clearAbortMarks UPDATE
  so a mark written against a non-streaming row is not erased

Item 4c — Takeover reconcile missing abortRequestedAt null:
- `stream-takeover.ts`: adds `abortRequestedAt: null` to the inline reconcile UPDATE,
  matching reconcileDeadStreamRows' shape

Tests: 13 new tests across pending-abort-intents, abort-stream-anywhere-pending,
and stream-lifecycle (pre-aborted path). All existing tests pass (113 total).

Closes #2028 items 1, 4a, 4b, 4c.
Items 2 and 3 are separate changes with their own migration risk.

Refs: #2022, #2028
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds durable pending-abort intents, consumes them during stream lifecycle creation, prevents pre-aborted generations from starting, and updates abort outcome handling, mark cleanup, takeover reconciliation, database schema, route wiring, expiry sweeping, and tests.

Changes

Pending abort flow

Layer / File(s) Summary
Pending abort intent storage
packages/db/src/schema/ai-streams.ts, packages/db/drizzle/0206_pending_abort_intents.sql, packages/db/drizzle/meta/*, apps/web/src/lib/ai/core/pending-abort-intents.ts, apps/web/src/lib/ai/core/__tests__/pending-abort-intents.test.ts
Adds the composite-key pending-abort table and helpers for recording, consuming with TTL handling, clearing, sweeping, and testing intents.
Abort request resolution
apps/web/src/lib/ai/core/abort-stream-anywhere.ts, apps/web/src/lib/ai/core/stream-abort-mark.ts, apps/web/src/lib/ai/core/stream-takeover.ts, apps/web/src/lib/ai/core/__tests__/abort-stream-anywhere-pending.test.ts
Records intents when no stream exists, reports locally aborted streams accurately, and restricts abort-mark cleanup to streaming rows.
Lifecycle pre-abort handling
apps/web/src/lib/ai/core/stream-lifecycle.ts, apps/web/src/lib/ai/core/__tests__/stream-lifecycle.test.ts
Consumes pending intents before and after insertion, creates or updates aborted sessions, skips stream activity, and exposes preAborted on lifecycle handles.
Route-level abort enforcement
apps/web/src/app/api/ai/chat/route.ts, apps/web/src/app/api/ai/global/[id]/messages/route.ts
Aborts the controller, removes the registry entry, and skips agent or command execution when lifecycle creation reports a pre-aborted stream.
Expiry sweep integration
apps/web/src/app/api/cron/sweep-expired/route.ts, apps/web/src/app/api/cron/sweep-expired/__tests__/route.test.ts, docker/cron/crontab
Adds pending-abort cleanup to the cron endpoint, aggregated results, audit data, failure handling, tests, and sweep documentation.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant abortStreamAnywhere
  participant PendingAbortIntents
  participant ChatRoute
  participant createStreamLifecycle
  participant aiStreamSessions
  participant streamText
  Client->>abortStreamAnywhere: request stop
  abortStreamAnywhere->>PendingAbortIntents: record intent when no stream exists
  ChatRoute->>createStreamLifecycle: create lifecycle
  createStreamLifecycle->>PendingAbortIntents: consume intent
  createStreamLifecycle->>aiStreamSessions: write aborted session
  createStreamLifecycle-->>ChatRoute: return preAborted lifecycle
  ChatRoute->>streamText: abort controller before generation
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The diff addresses the pre-INSERT abort gap, durable pending intents, ownership predicates, pre-aborted short-circuits, and the smaller abort-mark/takeover fixes from #2028.
Out of Scope Changes check ✅ Passed The changes stay focused on the abort-flow fix set; the migration, snapshots, tests, and cron updates all support the linked objective.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main fixes: closing the pre-INSERT abort window and tightening abort-mark predicates.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/pre-insert-abort-window

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.

)

Closes the remaining Stop gaps from #2028:

1. Pre-INSERT preflight window (item 1, critical)
   - New `ai_pending_abort_intents` table with migration 0206
   - `abortStreamAnywhere` records a durable pending-abort intent when
     it finds nothing in flight (Stop pressed during 0.5-3s preflight)
   - `createStreamLifecycle` consumes the intent at INSERT time; if
     present, writes the row as 'aborted' directly and returns
     preAborted=true so the route aborts the controller before streamText
   - Both chat routes handle preAborted by aborting + removing stream

4a. False unconfirmed when locally aborted (item 4a)
   - `abortStreamAnywhere` now returns ABORTED when mark fails but
     locally-aborted streams are stopped, instead of UNCONFIRMED

4b. clearAbortMarks missing status predicate (item 4b)
   - Added `eq(status, 'streaming')` to prevent clearing marks on
     non-streaming rows

4c. Takeover reconcile missing abortRequestedAt null (item 4c)
   - Inline reconcile in stream-takeover.ts now sets abortRequestedAt: null
     to match reconcileDeadStreamRows behavior

@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: c9f4dfc22a

ℹ️ 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".

//
// We INSERT the row as 'aborted' directly, skip multicast registration and the heartbeat, and
// return a pre-finished handle. The caller aborts the controller so streamText never starts.
const preAborted = await consumePendingAbort({ conversationId, userId });

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 Recheck pending aborts after inserting the stream row

When Stop lands after this one-shot consume returns false but before the ai_stream_sessions INSERT completes, the conversation-only abort path still cannot find a row and records a pending intent. This lifecycle never checks again, so the current generation starts anyway, and the just-recorded intent remains to pre-abort the user's next send within the 30s TTL. Please coordinate the consume with the insert, or re-check/update the row after insertion, so this narrow race does not both miss the requested Stop and poison the next request.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 4407c4d. Rechecked consumePendingAbort() immediately after the aiStreamSessions INSERT resolves (before the heartbeat/broadcast start), not just before it. A Stop landing in the gap you flagged now gets caught by this second check: the just-inserted row is UPDATEd to status='aborted', the multicast registry entry is evicted, and the handle returns preAborted: true — same contract as the pre-INSERT path, just one INSERT later. This closes the window down to the (now synchronous) gap between the recheck and streamText starting, rather than the full route preflight. Added 6 tests in stream-lifecycle.test.ts covering the late-intent path (UPDATE not INSERT, registry eviction, no broadcast, no-op handle, UPDATE-failure resilience). Leaving this thread open for verification.

Comment on lines +1448 to +1452
"idx": 206,
"version": "7",
"when": 1784053313000,
"tag": "0206_pending_abort_intents",
"breakpoints": true

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 Commit the Drizzle snapshot for migration 0206

This journal entry adds migration 0206 and the schema now declares aiPendingAbortIntents, but the commit does not include packages/db/drizzle/meta/0206_snapshot.json (the meta directory still stops at 0205_snapshot.json). The snapshots are the baseline for future generated migrations in this repo, so the next schema diff will not have ai_pending_abort_intents in the latest snapshot and can re-emit or fail around the same CREATE TABLE. Please add the generated 0206 snapshot with this migration.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 4407c4d. The committed 0206_pending_abort_intents_snapshot.json turned out to be doubly wrong, not just missing/misnamed: its prevId didn't chain from 0205_snapshot.json's id, and it didn't even declare the new ai_pending_abort_intents table — so it wasn't a valid snapshot for this migration at all. Regenerated the real snapshot by running drizzle-kit generate against a scratch out-folder truncated at 0205 (same schema, same config), confirmed the resulting CREATE TABLE SQL matches the already-committed 0206_pending_abort_intents.sql byte-for-byte on the meaningful parts (columns, PK), confirmed the diff adds exactly one table (ai_pending_abort_intents, nothing else drifted), and replaced the file as 0206_snapshot.json — matching the plain <idx>_snapshot.json naming every other entry in this repo's meta/ folder uses (drizzle-kit's own writeResult always names it that way; the tagged name was hand-added, not generated). _journal.json and the SQL migration were left untouched. Leaving this thread open for verification.

@coderabbitai coderabbitai Bot left a comment

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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/lib/ai/core/stream-lifecycle.ts (1)

112-241: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Close the consume/insert race in createStreamLifecycle (apps/web/src/lib/ai/core/stream-lifecycle.ts:120-241): consumePendingAbort() runs before the aiStreamSessions row exists, so a Stop that lands in that gap gets recorded as a pending intent after this call has already missed it. That drops the abort for the current generation and leaves an orphan intent that the next send on the same (conversationId, userId) can consume instead. Make the consume+insert path atomic or re-check before starting the stream.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/ai/core/stream-lifecycle.ts` around lines 112 - 241, Close
the race in createStreamLifecycle between consumePendingAbort and creation of
the aiStreamSessions row: ensure a Stop arriving during this window is applied
to the current stream generation rather than left for the next send. Make the
pending-abort consumption and session insertion atomic, or re-check pending
abort immediately before stream startup and mark the inserted row aborted when
found, while preserving the existing preAborted no-op behavior.
🧹 Nitpick comments (4)
packages/db/src/schema/ai-streams.ts (1)

86-111: 🚀 Performance & Scalability | 🔵 Trivial

Consider a periodic sweep for orphaned intents.

An intent that is never consumed (user never sends again on that conversation) has no other cleanup path than the TTL check inside consumePendingAbort, which only runs for that same (conversation_id, user_id) pair. The docstring itself notes a cron "would clean it up," but none exists in this change. Given the PK bounds this to at most one row per conversation+user, growth is not unbounded, but a scheduled sweep (DELETE WHERE created_at < now() - interval) would keep the table tidy without relying on that specific pair being touched again.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/schema/ai-streams.ts` around lines 86 - 111, The
aiPendingAbortIntents table lacks cleanup for intents that are never consumed.
Add a scheduled periodic sweep that deletes rows whose createdAt exceeds the
existing TTL, reusing the project’s established job or scheduler mechanism; keep
consumePendingAbort’s pair-specific TTL behavior unchanged.
apps/web/src/lib/ai/core/__tests__/abort-stream-anywhere-pending.test.ts (1)

77-88: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the return code too, not just the side effect.

This test only checks that recordPendingAbort wasn't called; it doesn't verify what result.code actually is for this specific "marked but finished here" branch.

♻️ Suggested addition
       const result = await abortStreamAnywhere({
         conversationId: 'conv1',
         userId: 'user1',
       });
 
       expect(recordPendingAbort).not.toHaveBeenCalled();
+      expect(result.code).toBe('not_found');
     });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/ai/core/__tests__/abort-stream-anywhere-pending.test.ts`
around lines 77 - 88, Update the “does NOT record a pending-abort when rows were
marked” test for abortStreamAnywhere to also assert the returned result.code for
the marked-but-recently-finished branch. Keep the existing recordPendingAbort
assertion and verify the branch’s expected success code.
apps/web/src/app/api/ai/chat/route.ts (1)

1233-1240: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Pre-aborted requests still run the whole generation setup instead of short-circuiting before createUIMessageStream.

In both routes, when lifecycle.preAborted is true, the code aborts the controller and removes the stream from the registry, but then falls through unconditionally into building createUIMessageStream/execute(), which still writes command-plan indicator parts, calls getModelCapabilities, and builds/calls runAgentWithRetrystreamText with the now-aborted signal. This should be safe in practice — providers forward abortSignal to the underlying fetch, and per the Fetch spec an already-aborted signal makes fetch() reject immediately without any network call — but the code relies on that implicit guarantee rather than a provable early exit, and still performs avoidable work (writer writes, tool-capability resolution, message/tool payload assembly) for a request that can never actually stream.

  • apps/web/src/app/api/ai/chat/route.ts#L1233-L1240: after setting preAborted, return the (empty/aborted) UI message stream response immediately rather than entering the try { createUIMessageStream(...) } block, or otherwise skip straight to execute's no-op path.
  • apps/web/src/app/api/ai/global/[id]/messages/route.ts#L1104-L1111: apply the same early-exit change here for consistency with the chat route.

Also worth adding a route-level test (not present in this batch) asserting the model provider is never actually invoked when lifecycle.preAborted is true — this is the property the whole feature exists to guarantee, and today it's only exercised down to the stream-lifecycle.ts boundary, not through the route.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/app/api/ai/chat/route.ts` around lines 1233 - 1240, Pre-aborted
requests must exit before entering the stream-generation setup. In
apps/web/src/app/api/ai/chat/route.ts lines 1233-1240, after handling
lifecycle.preAborted, immediately return the empty/aborted UI message stream
response; apply the same change in
apps/web/src/app/api/ai/global/[id]/messages/route.ts lines 1104-1111 so
createUIMessageStream, execute, and model invocation are skipped.
packages/db/drizzle/0206_pending_abort_intents.sql (1)

1-6: 🚀 Performance & Scalability | 🔵 Trivial

Consider an expiry sweep for orphaned intents.

Rows here are only cleaned up lazily, inside consumePendingAbort, when a later createStreamLifecycle call for the same (conversation_id, user_id) happens to run. If a user presses Stop during preflight and never sends another message on that conversation, the row lingers indefinitely (it's small, but unbounded across abandoned conversations). Worth considering a periodic sweep (similar to other reconcile/cron jobs in this codebase) that deletes rows older than the TTL.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/drizzle/0206_pending_abort_intents.sql` around lines 1 - 6,
Introduce a periodic expiry sweep for ai_pending_abort_intents that deletes rows
older than the configured pending-abort TTL, rather than relying only on
consumePendingAbort. Integrate it with the existing reconcile or cron-job
mechanism and reuse the established table and TTL symbols; preserve the current
lazy cleanup behavior as appropriate.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@apps/web/src/lib/ai/core/stream-lifecycle.ts`:
- Around line 112-241: Close the race in createStreamLifecycle between
consumePendingAbort and creation of the aiStreamSessions row: ensure a Stop
arriving during this window is applied to the current stream generation rather
than left for the next send. Make the pending-abort consumption and session
insertion atomic, or re-check pending abort immediately before stream startup
and mark the inserted row aborted when found, while preserving the existing
preAborted no-op behavior.

---

Nitpick comments:
In `@apps/web/src/app/api/ai/chat/route.ts`:
- Around line 1233-1240: Pre-aborted requests must exit before entering the
stream-generation setup. In apps/web/src/app/api/ai/chat/route.ts lines
1233-1240, after handling lifecycle.preAborted, immediately return the
empty/aborted UI message stream response; apply the same change in
apps/web/src/app/api/ai/global/[id]/messages/route.ts lines 1104-1111 so
createUIMessageStream, execute, and model invocation are skipped.

In `@apps/web/src/lib/ai/core/__tests__/abort-stream-anywhere-pending.test.ts`:
- Around line 77-88: Update the “does NOT record a pending-abort when rows were
marked” test for abortStreamAnywhere to also assert the returned result.code for
the marked-but-recently-finished branch. Keep the existing recordPendingAbort
assertion and verify the branch’s expected success code.

In `@packages/db/drizzle/0206_pending_abort_intents.sql`:
- Around line 1-6: Introduce a periodic expiry sweep for
ai_pending_abort_intents that deletes rows older than the configured
pending-abort TTL, rather than relying only on consumePendingAbort. Integrate it
with the existing reconcile or cron-job mechanism and reuse the established
table and TTL symbols; preserve the current lazy cleanup behavior as
appropriate.

In `@packages/db/src/schema/ai-streams.ts`:
- Around line 86-111: The aiPendingAbortIntents table lacks cleanup for intents
that are never consumed. Add a scheduled periodic sweep that deletes rows whose
createdAt exceeds the existing TTL, reusing the project’s established job or
scheduler mechanism; keep consumePendingAbort’s pair-specific TTL behavior
unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: aa67f803-f440-4324-a7f6-f5bd9c529bbc

📥 Commits

Reviewing files that changed from the base of the PR and between 0847eb7 and c9f4dfc.

📒 Files selected for processing (13)
  • apps/web/src/app/api/ai/chat/route.ts
  • apps/web/src/app/api/ai/global/[id]/messages/route.ts
  • apps/web/src/lib/ai/core/__tests__/abort-stream-anywhere-pending.test.ts
  • apps/web/src/lib/ai/core/__tests__/pending-abort-intents.test.ts
  • apps/web/src/lib/ai/core/__tests__/stream-lifecycle.test.ts
  • apps/web/src/lib/ai/core/abort-stream-anywhere.ts
  • apps/web/src/lib/ai/core/pending-abort-intents.ts
  • apps/web/src/lib/ai/core/stream-abort-mark.ts
  • apps/web/src/lib/ai/core/stream-lifecycle.ts
  • apps/web/src/lib/ai/core/stream-takeover.ts
  • packages/db/drizzle/0206_pending_abort_intents.sql
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/schema/ai-streams.ts

…ng-abort intents

Addresses PR #2062 review feedback (Codex + CodeRabbit):

- stream-lifecycle.ts: recheck consumePendingAbort() immediately after the
  aiStreamSessions INSERT, not just before it. A Stop landing in the gap
  between the pre-INSERT check and the INSERT resolving previously missed
  the current generation AND left an orphaned intent to wrongly pre-abort
  the next send within the 30s TTL.

- packages/db/drizzle/meta/0206_snapshot.json: the committed snapshot was
  misnamed (0206_pending_abort_intents_snapshot.json) and internally wrong
  — its prevId didn't chain from 0205 and it didn't even declare the new
  table. Regenerated via drizzle-kit against a scratch out-folder truncated
  at 0205, verified the resulting SQL matches the already-committed
  0206_pending_abort_intents.sql, and replaced the file under the
  convention every other snapshot in this repo follows.

- chat/route.ts, global/[id]/messages/route.ts: short-circuit inside
  execute() when lifecycle.preAborted, skipping command-plan writes,
  model-capability resolution, and the agent loop instead of relying on
  the already-aborted signal to short-circuit streamText's fetch.

- pending-abort-intents.ts: add sweepExpiredPendingAbortIntents(), wired
  into the existing /api/cron/sweep-expired route alongside the other
  append-with-TTL table sweeps. Closes the gap where an intent from a
  conversation that's never resumed would sit past its TTL indefinitely.

- abort-stream-anywhere-pending.test.ts: assert result.code for the
  "marked but finished here" branch, not just the recordPendingAbort
  side effect.

65 stream-lifecycle/pending-abort tests + 18 sweep-route tests pass;
full ai/core + chat + global test directories (1062 tests) green;
typecheck and lint clean.
@2witstudios

Copy link
Copy Markdown
Owner Author

Pushed 4407c4d addressing review feedback so far:

  • Codex P2 / CodeRabbit Major — consume/insert race in createStreamLifecycle: fixed with a post-INSERT recheck of consumePendingAbort() (replied inline on the thread).
  • Codex P2 — missing/wrong Drizzle 0206 snapshot: regenerated correctly, replied inline on the thread.
  • CodeRabbit nitpick — pre-aborted requests still building the full generation setup: fixed. Both routes now return at the top of execute() when lifecycle.preAborted, skipping command-plan writes, getModelCapabilities, and runAgentWithRetry entirely — onFinish still runs (on essentially-empty output) so the credit-hold-release/persistence path is untouched. Didn't add the suggested route-level "model never invoked" integration test: there's no existing precedent in this repo for full end-to-end route-handler mocking (auth+DB+streamText) at that granularity, and the property is now enforced by an unconditional early return rather than relying on an aborted signal — which the 52 tests in stream-lifecycle.test.ts already cover at the boundary CodeRabbit flagged. Happy to add it if you'd still like it as a follow-up.
  • CodeRabbit nitpick — assert result.code in the "marked but finished here" test: done.
  • CodeRabbit nitpick (2x) — periodic sweep for orphaned pending-abort intents: added sweepExpiredPendingAbortIntents(), wired into the existing /api/cron/sweep-expired route (already running every 15 min) alongside sweepExpiredRateLimitBuckets/sweepExpiredAuthHandoffTokens — same append-with-TTL pattern, no new cron infra.

All CI checks were green before this push (typecheck, lint, unit tests, security suite); re-running now. 1062 tests pass locally across the touched directories.

…heck

Self-review (8 independent finder agents) surfaced that the two
sequential consumePendingAbort() calls added in 4407c4d (one before
the aiStreamSessions INSERT, one after) were:

- Duplicative: both branches wrote near-identical 'aborted' state and
  the same noop-handle literal, just via INSERT vs UPDATE.
- Wasteful: every non-preaborted stream start (the overwhelming
  majority) paid for 2 DB round-trips to check an intent that almost
  never exists, on a path this whole feature calls TTFB-sensitive.

Collapsed to ONE check, placed after the INSERT: since nothing else
consumes a pending-abort intent in between, checking once there covers
both the original pre-INSERT-window case and the narrower insert-race
case the prior commit added, at 1 fewer DB round-trip per stream start
and ~95 fewer lines.

Also tightened the check's comment to disclose the still-open (but
narrow, TTL-bounded, self-healing) commit-ordering race between this
consume and a concurrent recordPendingAbort — matching this codebase's
own KNOWN RACE disclosure convention rather than implying the window is
fully closed. And de-duplicated the identical 5-line comment copied
into both routes' execute() guards down to a 3-line pointer at
StreamLifecycleHandle.preAborted's existing JSDoc.

48 stream-lifecycle tests (down from 52, net fewer due to the merge)
+ full ai/core + chat + global + cron test dirs (1058 tests) pass;
typecheck and lint clean.
@2witstudios

Copy link
Copy Markdown
Owner Author

Pushed b48cf13 — a self-review pass (8 independent finder agents: line-scan, removed-behavior audit, cross-file tracer, reuse, simplification, efficiency, altitude, CLAUDE.md conventions) on top of 4407c4d surfaced that my own consume/insert-race fix was itself improvable:

  • Simplification + reuse findings: the pre-INSERT and post-INSERT pending-abort checks I'd added were near-duplicate blocks (same 'aborted' write shape, same noop-handle literal, just INSERT-vs-UPDATE).
  • Efficiency finding: that meant every non-preaborted stream start — the overwhelming majority — paid for 2 sequential DB round-trips checking an intent that almost never exists, on a path this whole feature calls TTFB-sensitive.

Fix: collapsed to one check, placed after the INSERT. Since nothing else consumes a pending-abort intent in the interim, checking once there covers both the original pre-INSERT-window case (#2028 item 1) and the insert-race case — 1 fewer DB round-trip per stream start, ~95 fewer lines, same guarantee.

Also acted on:

  • Altitude finding: tightened the check's comment to disclose the still-open (narrow, single-digit-ms, TTL-bounded, self-healing) commit-ordering race between this consume and a concurrent recordPendingAbort — matching this repo's own KNOWN RACE disclosure convention in chat/route.ts instead of implying full closure.
  • Simplification finding: de-duplicated the identical 5-line comment copied into both routes' execute() guards down to a 3-line pointer at StreamLifecycleHandle.preAborted's existing JSDoc.

Not acted on (documented, not silently dropped):

  • Efficiency: an atomic single-query alternative (CTE: DELETE the intent + INSERT/UPSERT the session row in one round-trip, fully closing rather than narrowing the race) is architecturally the "best" answer, but it's raw-SQL, bigger surface area, and more risk than this convergence pass should take on. Worth a follow-up issue if the residual sliver ever proves non-negligible in practice.
  • Efficiency: the 4 cron sweeps in /api/cron/sweep-expired run sequentially rather than via Promise.allSettled. Pre-existing pattern for the other 3 sweeps (not something this PR introduced), and it's a 15-minute cron job, not a latency-sensitive path — left as-is for consistency with the established shape.

CI: all required checks green (CodeQL, CodeQL Security Analysis, Dependency Audit, Security Test Suite, Secret Scanning, Static Security Analysis, Lint & TypeScript, CodeRabbit re-review clean) as of the prior push; re-running now against b48cf13. 1058 tests pass locally across the touched directories, typecheck/lint clean.

@2witstudios
2witstudios merged commit a0030d4 into master Jul 14, 2026
10 checks passed
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.

AI stream aborts: close the remaining Stop gaps left out of #2022

1 participant