Skip to content

fix(ai): Stop-button / stream-ownership machine (stacked on #2018)#2011

Open
2witstudios wants to merge 1 commit into
pu/server-owned-streams-corefrom
pu/server-owned-streams
Open

fix(ai): Stop-button / stream-ownership machine (stacked on #2018)#2011
2witstudios wants to merge 1 commit into
pu/server-owned-streams-corefrom
pu/server-owned-streams

Conversation

@2witstudios

@2witstudios 2witstudios commented Jul 11, 2026

Copy link
Copy Markdown
Owner

This is PR 2 of 2. It is stacked on #2018 — review and merge that first.

#2018 carries the server-owned-streams architecture (all seven ACs, the IDOR, the privacy leak, the AC7 tripwire). This PR carries the part that kept breaking.

Read this before reviewing

This PR is the reason the original #2011 went fourteen rounds. Twenty-six-plus defects, all in this subsystem, and the rate never fell. Two of the most recent were introduced by me, while fixing something else. It has been split out precisely so it gets the focused review that record demands, instead of riding along with an architecture that has been stable for nine rounds.

Please do not review this as a routine diff. Every bug here has been the same bug:

A piece of state that named the SURFACE when it needed to name the STREAM.

A chatId, a panel, a tab, the live messages array — used where an (agentId, conversationId, messageId) triple was required. The symptoms were always some combination of: a Stop button lit for a stream the user isn't looking at; a Stop button that looks like it works and silently does nothing; a composer permanently disabled; one conversation's tokens rendered into another; and, in every case, a server that kept generating, kept running write tools, and kept billing.

Streams deliberately survive a client disconnect (that is the whole architecture). So a Stop that misses is not a cosmetic bug. It is a runaway agent and a real invoice.

What is in here

  • holdForStream — capture a stream's identity when it starts, hold it until it ends. The surface moves independently; the stream's identity does not.
  • selectLiveAssistantIds — resolves each chat's live assistant messageId from its own status and messages. This exists because the bug lived in the wiring, not the reducer: a test of holdForStream is blind to which chat's id the caller hands it, and passed happily while the caller handed it the wrong one.
  • shouldClaimAgentStopSlot / shouldClaimGlobalStopSlot — single-writer guards on the shared stop slots.
  • The four surfaces + the page-agents store, rekeyed by (agentId, conversationId).

Three that are worth understanding, because they will come back otherwise

1. Stop aborted the PREVIOUS turn's message, on every turn after the first.
useChat sets status: 'submitted' before issuing the request, and pushes the new assistant message only inside write(), which flips the status to 'streaming' in the same job. So for the whole submitted window, the array's last assistant message is the previous turn's reply. holdForStream latches on the first live render — a submitted render — so it captured and held a message that had finished minutes ago. Stop then aborted an id the registry no longer knew: the fetch stopped, the button looked like it worked, and the real generation kept running.

Every messageId here now resolves only at status === 'streaming'. holdForStream's docstring used to state the false premise outright ("captured when the first chunk arrives") — that sentence is what the bug was made of, and it is corrected in place.

2. The dedup guard truncated the reply. "We have a message with this id" is not "we have its content." The server names the assistant message, so useChat's copy shares the stream's id — and useChat does not roll back on error, so a mid-stream network drop leaves its half-streamed message in the array. That is exactly when recovery rejoins the multicast and the full reply arrives. Skipping threw the complete version away. It now replaces by id.

3. A surface may only free the activeStreams key it allocated. The sidebar registered sidebar:<convId> and cleared the bare <convId> — a key it never writes, and which belongs to the dashboard. It leaked its own entry and deleted someone else's.

The structural fix this subsystem still wants

Every one of these is a string used as a key, correct by convention and by comment. The real fix is to make the key a branded type the transport alone can mint, so "named the surface instead of the stream" becomes a compile error rather than a bug found in round fifteen.

I did not do that here. Doing a wide-blast-radius refactor of the highest-defect subsystem, at the tail of a long PR, with my own recent error rate, is how bug #29 gets written. It should be a deliberate follow-up.

Validation

tsc clean · lint 14/14 · 12,688 tests passing (3 pre-existing failures, reproduced on clean master).

Every fix is pinned by a test I verified fails without it. That habit is not ceremony: earlier in this work a regression test of mine passed with the bug deliberately reintroduced (the helper reimplemented the production selector instead of calling it), and two bugs survived review by hiding behind fixtures that modelled impossible states. When a fix could not be pinned by a pure test, the wiring itself was extracted into a tested function — that is what selectLiveAssistantIds is.

🤖 Generated with Claude Code

https://claude.ai/code/session_016ie3Bmn3sgiUJ7a6ErwEc4

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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

Review profile: CHILL

Plan: Pro

Run ID: c1acd728-3933-4c96-a59d-10bd081a39b3

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

Walkthrough

Changes

AI stream lifecycle and authorization

Layer / File(s) Summary
Heartbeat persistence and takeover
apps/web/src/lib/ai/core/*, packages/db/src/schema/ai-streams.ts, packages/db/drizzle/*
Stream sessions now persist heartbeats, use a shared lifetime horizon, detect stale rows, and reconcile streams during per-conversation takeover.
Conversation and stream authorization
apps/web/src/app/api/ai/chat/*, apps/web/src/app/api/ai/global/..., apps/web/src/lib/ai/core/stream-subscription-authz.ts, apps/web/src/lib/repositories/conversation-repository.ts
Routes validate conversation IDs, ownership, sharing, and page context; active streams and stream joins apply liveness and subscription filtering.
Client stream attachment
apps/web/src/hooks/useChannelStreamSocket.ts, apps/web/src/lib/ai/core/stream-abort-client.ts, apps/web/src/lib/ai/streams/*
Channel consumption tracking, privacy-aware attachment, failed-join recovery, and reconnect bootstrap behavior were added.
Conversation identity and UI state
apps/web/src/lib/ai/shared/hooks/*, apps/web/src/components/layout/middle-content/page-views/ai-page/*
Chat identities now distinguish persisted conversations from newly minted CUIDs, while optimistic messages and pending streams are scoped by conversation.
Stop ownership and recovery
apps/web/src/contexts/GlobalChatContext.tsx, apps/web/src/hooks/useAgentChannelMultiplayer.ts, apps/web/src/stores/page-agents/*, apps/web/src/components/layout/...
Global and agent stop controls use ownership-aware slots, and resume gating evaluates editing state at event time.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 63.64% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the PR’s main theme: stop-button and stream-ownership changes for AI chat streams.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/server-owned-streams

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.

@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: 332c691e4e

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

// In-memory and single-process: a no-op when the stream belongs to another
// instance (or already ended). The reconcile below is what actually
// guarantees the conversation is unblocked.
const result = abortStreamByMessageId({ messageId, 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.

P1 Badge Abort streams with their owner, not the takeover user

In a shared conversation, if user B sends while user A's generation is still running, this calls abortStreamByMessageId with B's user id. The abort registry authorizes aborts against the stream owner, so the call returns unauthorized, after which the row is still marked aborted and B's new stream starts while A's old generation continues editing/billing in the background. Select/pass the row's original userId (or use an internal abort path) so takeover actually stops live streams started by other participants.

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.

Correct, and fixed in a70e1d1 — thank you, this was the sharpest finding on the PR.

You're right that the abort registry authorizes against the stream's owner (stream-abort-registry.ts:141-143), so passing the caller's id made the guard refuse every cross-user abort. On a shared conversation that meant user B's send left user A's generation running — still calling tools, still editing pages, still billing — while B's new generation started beside it. Exactly the double-generation this guard exists to prevent.

That IDOR guard exists for the client-facing POST /api/ai/abort endpoint. Takeover is not that: it's a trusted server-side path, and the caller's right to write to this conversation is already established upstream (page edit access, plus a new ownership/shared check on conversationId in the route). So takeOverConversationStreams now selects aiStreamSessions.userId and aborts as the owner:

const owner = ownerByMessageId.get(messageId);
if (!owner) continue;
const result = abortStreamByMessageId({ messageId, userId: owner });

The userId parameter is gone from the function signature entirely, so there is no way to reintroduce the caller-id variant by accident. Pinned by a test that asserts the abort is issued with the owner's id and that the takeover actually succeeds: "given a live stream owned by ANOTHER user, should abort it as its OWNER so the takeover actually stops it" (stream-takeover.test.ts).

Related: the second half of what you describe — the row being marked aborted even when the abort was refused — is also fixed. decideStreamTakeover now only reconciles rows we can prove are finished (actually aborted, or provably dead). A live row we cannot stop is left alone and logged, because stamping status='aborted', parts=[] over a running stream hides it from every subscriber and destroys its only crash-recovery snapshot.

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.

Bookkeeping: this file has moved to #2018.

This PR was split. stream-takeover.ts is no longer part of its diff — it now lives in #2018 (the server-owned-streams core), which this PR is stacked on.

The concern you raised (abort streams with the row's own owner id, not the caller's) was fixed and is in #2018. To restate it so it isn't lost in the move: the abort is issued as the stream's owner (row.userId), not the caller's. Passing the caller's id made the registry's ownership guard refuse every cross-user abort — so on a shared conversation, user B's send would leave user A's generation running (still calling tools, still editing pages, still billing) while B's new one started beside it. Which is the entire thing the takeover exists to prevent.

Also since fixed in #2018, from a later review: the takeover used to lie on partial failure — if the aborts landed but the reconcile UPDATE threw, it reported {aborted: [], reconciled: []} ("nothing happened") when streams had in fact been stopped.

Leaving this thread open rather than resolving it, since it's yours to verify — but please verify it against #2018, not here.

now: number;
staleAfterMs?: number;
}): StreamTakeoverDecision => ({
abort: rows.filter((r) => isStreamRowLive(r, now, staleAfterMs)).map((r) => r.messageId),

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 Do not skip aborting quiet but still-running streams

When a live generation is quiet for more than two minutes (for example during a long tool call, or before enough chunks arrive to hit the 20-part checkpoint), isStreamRowLive returns false and this takeover decision omits it from abort. The subsequent reconcile marks the DB row aborted, but the in-process abort controller is never signaled, so a second send can run concurrently with the still-running first stream and the old stream can later keep writing/tool-editing. Takeover should still attempt to abort every in-flight row that may be owned by this process, using liveness only to decide what to expose as resumable.

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.

Agreed and fixed — this was a genuine defect that defeated AC5, and my original code comment had the logic exactly backwards (it claimed misjudging a live row as stale was "harmless").

Two changes:

1. Liveness no longer gates the abort. As you say, aborting an unknown messageId is free (abortStreamByMessageId returns {aborted:false}; it does not throw), whereas skipping an abort for a row we misjudged leaves a real generation running. The asymmetry is total, so decideStreamTakeover now returns every in-flight row in abort, and liveness is used only to decide what may be driven terminal:

abort: rows.map((r) => r.messageId),
reconcile: rows
  .filter((r) => aborted.has(r.messageId) || !isStreamRowLive(r, now, staleAfterMs))
  .map((r) => r.messageId),

That also fixes the reconcile half you flag: a row we could not abort but that is still beating is now left alone, rather than being stamped aborted while it keeps writing.

2. The heartbeat is now a real timer, not the parts checkpoint. Your parenthetical is the root cause: a stream in a long tool call pushes no parts at all, so a checkpoint-driven heartbeat could never have covered it — no value of PERSIST_EVERY_N_PARTS would. stream-lifecycle.ts now writes lastHeartbeatAt on a 20s interval (cleared in finish(), unref'd), independent of pushPart. Without this the same quiet stream also vanished from GET /active-streams mid-flight, so no client could attach to it — which is what walked the user into sending again in the first place.

Tests pinning both: "given a row that LOOKS stale, should still attempt to abort it (a liveness guess must never gate the abort)", "given a LIVE row we could not abort, should NOT reconcile it" (stream-liveness.test.ts), and "given a stream that pushes NO parts at all (a long tool call), should still beat" (stream-lifecycle.test.ts).

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.

Bookkeeping: this file has moved to #2018.

This PR was split. stream-liveness.ts is no longer part of its diff — it now lives in #2018 (the server-owned-streams core), which this PR is stacked on.

Your point (do not skip aborting a quiet stream) was fixed and is in #2018. Restating it so it survives the move, because it was the most important correction in the whole effort: liveness must never gate the abort. A stream sitting in a long tool call pushes no parts and looks idle — it is quiet, not dead. The original guard skipped aborting it, marked its row aborted, and started a second generation beside the first. Exactly the double-generation the guard was written to prevent.

The split is now: abort EVERY in-flight row, live-looking or not (a redundant abort is a cheap no-op; a skipped one leaves a real generation running), and reconcile only what was actually stopped plus what is provably dead.

Leaving this open for you to verify — but against #2018, not here.

@2witstudios

Copy link
Copy Markdown
Owner Author

Convergence summary — what changed since the initial review

Four adversarial review passes over this diff surfaced defects serious enough that the original AC5 guard was not merely incomplete, it was wrong. Flagging them here because the PR now differs materially from what was first reviewed, and two of them are security issues.

The AC5 guard was defeating itself

  1. A liveness guess gated the abort. The heartbeat rode the 20-part parts-checkpoint while the stale window was 2 minutes — so a stream inside a long tool call (>2 min, <20 parts) looked dead. decideStreamTakeover then put it in reconcile but not abort: the running generation was never stopped, and a second one started beside it. Precisely the double-generation the guard exists to prevent. Aborting an unknown messageId is free; skipping an abort is not — so every in-flight row now gets an abort attempt, unconditionally.
  2. Reconcile lied about streams it couldn't stop. A row whose abort was refused (cross-user) or not-found (another web instance) was still stamped status='aborted', parts=[] — hiding a live stream from every subscriber and destroying its only crash-recovery snapshot while it kept running, kept calling tools, and kept billing. Now only rows we can prove are finished get reconciled.
  3. The heartbeat couldn't work. A quiet tool call pushes no parts at all, so no checkpoint-driven heartbeat could ever have covered it. It's now a real 20s timer — capped at 15 min, because an unbounded beat on a lifecycle whose finish() never fired would be strictly worse than none (an immortal live-looking row that can never be reconciled or taken over).
  4. Takeover aborted as the caller, not the stream's owner (thanks @chatgpt-codex-connector — P1). The abort registry authorizes against the owner, so cross-user aborts were refused every time: on a shared conversation, B's send left A's generation running while B's started beside it.

Security

  1. IDOR in the new conversationId validation. The history load is keyed on (pageId, conversationId) with no user filter, and the id I was grandfathering — ${pageId}-default — is derivable from the page id. Any member with edit access could read a co-member's private conversation. Now authorized (own or explicitly shared, and belonging to this page), and it fails closed on legacy conversations and on DB errors.

  2. Stream subscription was authorized on page access alone — pre-existing on master, but this PR is what makes streams a first-class subscribable entity, so it had to be answered. A page channel carries every conversation on the page, and conversations are private by default: /active-streams (which returns the buffered parts snapshot) and /stream-join (which streams the tokens) would hand a co-member's private conversation to anyone who could open the page.

    The fix is deliberately not a new policy — it's the existing one, finally applied to streams. listConversations already filters WHERE conv."userId" = $me OR conv."isShared" = true; subscription now enforces exactly that, so what you can watch matches what you can list.

One of my own fixes was a regression, and was reverted

While restoring the sentinel's one deliberate property ("concurrent openers share a conversation"), I dropped the conversation filter whenever the conversation was unpersisted — which is the normal state of a fresh page, New Chat, and post-delete. That rendered every stream on the page channel, i.e. other members' private conversations, and gave a blank "New Chat" a Stop button wired to the old conversation. The property I was chasing was the leak. Reverted; scoping is unconditional.

Verification

tsc --noEmit clean · lint clean (14/14) · test:unit@pagespace/lib 7545 passed / 0 failed; web 12534 passed, 848 files. The 3 remaining web failures (activity-tools, admin-role-version, grouping — 16 tests) are pre-existing: they need a Postgres role absent from this environment plus one timezone-dependent assertion. I reproduced the identical 16 failures on a clean origin/master worktree to confirm they are not mine.

Every fix above is pinned by a test that fails without it.

@chatgpt-codex-connector

Copy link
Copy Markdown

Summary

  • Updated the stream takeover documentation so it matches the current guard behavior: every in-flight stream gets an abort attempt, and only streams proven finished are reconciled. apps/web/src/lib/ai/core/stream-takeover.tsL11-L14
  • Updated the liveness documentation to describe the dedicated heartbeat timer rather than the obsolete parts-checkpoint heartbeat wording. apps/web/src/lib/ai/core/stream-liveness.tsL14-L15
  • Committed the change on the current branch: dc7cfa0 docs(ai): align stream takeover comments.
  • Created a pull request with title: docs(ai): align stream takeover comments.

Testing

  • bun test apps/web/src/lib/ai/core/__tests__/stream-liveness.test.ts
  • ⚠️ bun test apps/web/src/lib/ai/core/__tests__/stream-liveness.test.ts apps/web/src/lib/ai/core/__tests__/stream-takeover.test.ts apps/web/src/lib/ai/core/__tests__/stream-lifecycle.test.ts apps/web/src/lib/ai/core/__tests__/stream-subscription-authz.test.ts — warning due to environment/module resolution limitations: several imported test files could not resolve dependencies such as drizzle-orm/node-postgres, drizzle-orm, and @/lib/websocket; the liveness test file within the command passed.

View task →

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

Actionable comments posted: 4

Caution

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

⚠️ Outside diff range comments (2)
apps/web/src/lib/ai/shared/hooks/useConversationIdentity.ts (1)

74-88: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Stale resolve can still overwrite isPersisted. RESOLVED is dropped once identity moves on, but setIsPersisted(result.isPersisted ?? true) still runs unguarded. That can leave isPersisted out of sync with the active conversation and break the load/refresh logic that depends on it in AiChatView. Gate this update the same way as RESOLVED, or move isPersisted into the reducer state.

🤖 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/shared/hooks/useConversationIdentity.ts` around lines 74
- 88, The runResolve callback currently lets stale resolve results update
isPersisted even when the conversation identity has changed. Guard
setIsPersisted with the same active-resolution/identity check used before
dispatching RESOLVED, or move isPersisted into the reducer so stale results are
rejected consistently; keep AiChatView’s active-conversation load and refresh
behavior synchronized.
apps/web/src/hooks/useChannelStreamSocket.ts (1)

228-255: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Do not treat an SSE join failure as stream finalization.

The code correctly notes that a 404 can mean the stream is still running on another web instance, but it immediately calls fireOwnFinalize. That clears the owning surface’s streaming/Stop state before chat:stream_complete; bootstrap snapshots remain rendered as non-live, while unseeded streams disappear. Keep ownership and Stop state until terminal completion/abort, while separately recording that token consumption is unavailable.

🤖 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/hooks/useChannelStreamSocket.ts` around lines 228 - 255, Remove
the fireOwnFinalize call from the SSE join-rejection path in
useChannelStreamSocket, so a failed join only records joinFailed and applies the
existing snapshot/removal behavior without finalizing ownership or Stop state.
Ensure finalization remains driven by chat:stream_complete or an explicit abort,
while preserving the separate handling of seeded and unseeded streams.
🧹 Nitpick comments (3)
apps/web/src/app/api/ai/chat/active-streams/__tests__/route.test.ts (1)

50-51: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the duplicate filterSubscribableStreams mock entry.

The vi.mock factory object defines filterSubscribableStreams twice with identical implementations. The second silently overwrites the first — harmless here since they're identical, but it's a copy-paste artifact that will confuse future readers.

🧹 Proposed fix
 vi.mock('`@/lib/ai/core/stream-subscription-authz`', () => ({
   filterSubscribableStreams: (args: { rows: unknown[] }) => mockFilterSubscribableStreams(args),
-  filterSubscribableStreams: (args: { rows: unknown[] }) => mockFilterSubscribableStreams(args),
 }));
🤖 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/active-streams/__tests__/route.test.ts` around
lines 50 - 51, Remove the duplicate filterSubscribableStreams property from the
vi.mock factory in the active-streams route test, keeping one implementation
that delegates to mockFilterSubscribableStreams.
apps/web/src/app/api/ai/chat/stream-join/[messageId]/route.ts (1)

44-62: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate page-access check — extract a shared helper.

hasPageAccess and the pageOk computation inside hasViewAccess compute the identical channelOwner !== null ? channelOwner === userId : await canUserViewPage(...) expression independently. Since this is security-relevant logic re-evaluated on every periodic recheck, keeping two copies in sync manually is a future-drift risk.

♻️ Proposed refactor to share the check
+  const checkPageAccess = async (): Promise<boolean> =>
+    channelOwner !== null ? channelOwner === userId : canUserViewPage(userId, meta.pageId);
+
-  const hasPageAccess = channelOwner !== null
-    ? channelOwner === userId
-    : await canUserViewPage(userId, meta.pageId);
+  const hasPageAccess = await checkPageAccess();

   ...

   const hasViewAccess = async (): Promise<boolean> => {
-    const pageOk = channelOwner !== null
-      ? channelOwner === userId
-      : await canUserViewPage(userId, meta.pageId);
+    const pageOk = await checkPageAccess();
     if (!pageOk) return false;
     return canSubscribe();
   };

Also applies to: 81-90

🤖 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/stream-join/`[messageId]/route.ts around lines
44 - 62, Extract the duplicated page-access expression into a shared helper near
hasViewAccess, accepting channelOwner, userId, and meta.pageId as needed.
Replace both the hasPageAccess assignment and the pageOk computation inside
hasViewAccess with calls to this helper, preserving the existing authorization
outcomes and recheck behavior.
apps/web/src/app/api/ai/chat/route.ts (1)

409-462: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Redundant DB round-trips for new conversations — route now duplicates createConversation's internal checks.

The new validation block already resolves both "does a conversations row exist" (via getConversation) and "is there a conflicting message owner" (via hasConflictingMessageOwner) for a fresh cuid conversationId. conversationRepository.createConversation (called shortly after, at line 527) re-runs both checks internally, doubling the query count on the first-message-of-a-new-conversation path.

Consider threading the already-known result through, e.g. an options flag so createConversation can skip its own existence/conflict checks when the caller has already validated them:

♻️ Proposed sketch to avoid the duplicate queries
- await conversationRepository.createConversation(conversationId, userId!, chatId).catch(() => {});
+ await conversationRepository.createConversation(conversationId, userId!, chatId, {
+   // Route-level validation above already confirmed no row exists and no
+   // conflicting owner, when requestConversationId was supplied.
+   skipConflictCheck: Boolean(requestConversationId) && !existingConversation,
+ }).catch(() => {});
// conversation-repository.ts
async createConversation(
  conversationId: string,
  userId: string,
  pageId: string,
  options?: { skipConflictCheck?: boolean },
): Promise<void> {
  const [existing] = await db.select({ id: conversations.id }).from(conversations)
    .where(eq(conversations.id, conversationId)).limit(1);
  if (existing) return;

  if (!options?.skipConflictCheck) {
    const hasConflictingOwner = await hasConflictingMessageOwner(pageId, conversationId, userId);
    if (hasConflictingOwner) return;
  }
  ...
}

Also applies to: 527-527

🤖 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 409 - 462, Thread the
route’s completed conversation validation into createConversation to avoid
repeating database checks for a validated requestConversationId. Add an explicit
option recognized by conversationRepository.createConversation to skip both
existence and conflicting-message-owner queries only when the route already
performed them, while retaining the current checks for all other callers. Pass
that option at the createConversation call site after the validation block.
🤖 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.

Inline comments:
In
`@apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx`:
- Around line 480-488: The useEffect sync guard can record a conversation as
synced before it exists in the refreshed list. Update the effect around
syncedConversationRef and refreshConversations so the ref is assigned only after
confirming conversations contains currentConversationId, or otherwise defer the
refresh until sending has started; ensure a later effect run can retry when the
row appears.

In `@apps/web/src/hooks/useChannelStreamSocket.ts`:
- Around line 50-52: Make own-stream finalization scoped to the
conversation/claim that bootstrapped the stream: update onOwnStreamFinalize and
the related finalize paths to carry and validate the conversation or claim
identity before clearing state. Ensure a rejected stream cannot finalize or
clear another stream’s Stop control, while preserving finalization for the
stream that actually owns the slot.
- Around line 395-402: The failed-join completion flow around joinFailed,
removeStream, and onStreamComplete must preserve global reload behavior. Pass
explicit join-failure metadata to the completion handler, or update the global
completion handling to reload when the conversation matches and the stream entry
is absent, ensuring persisted assistant content is fetched after removal.

In `@apps/web/src/lib/ai/core/__tests__/stream-lifecycle.test.ts`:
- Around line 284-289: Update the comment above the mockUpdateSet expectation in
the stream lifecycle test to remove the outdated claim that no separate
heartbeat timer exists, and accurately describe this assertion as verifying the
parts checkpoint write and its lastHeartbeatAt timestamp. Leave the independent
heartbeat timer tests and behavior unchanged.

---

Outside diff comments:
In `@apps/web/src/hooks/useChannelStreamSocket.ts`:
- Around line 228-255: Remove the fireOwnFinalize call from the SSE
join-rejection path in useChannelStreamSocket, so a failed join only records
joinFailed and applies the existing snapshot/removal behavior without finalizing
ownership or Stop state. Ensure finalization remains driven by
chat:stream_complete or an explicit abort, while preserving the separate
handling of seeded and unseeded streams.

In `@apps/web/src/lib/ai/shared/hooks/useConversationIdentity.ts`:
- Around line 74-88: The runResolve callback currently lets stale resolve
results update isPersisted even when the conversation identity has changed.
Guard setIsPersisted with the same active-resolution/identity check used before
dispatching RESOLVED, or move isPersisted into the reducer so stale results are
rejected consistently; keep AiChatView’s active-conversation load and refresh
behavior synchronized.

---

Nitpick comments:
In `@apps/web/src/app/api/ai/chat/active-streams/__tests__/route.test.ts`:
- Around line 50-51: Remove the duplicate filterSubscribableStreams property
from the vi.mock factory in the active-streams route test, keeping one
implementation that delegates to mockFilterSubscribableStreams.

In `@apps/web/src/app/api/ai/chat/route.ts`:
- Around line 409-462: Thread the route’s completed conversation validation into
createConversation to avoid repeating database checks for a validated
requestConversationId. Add an explicit option recognized by
conversationRepository.createConversation to skip both existence and
conflicting-message-owner queries only when the route already performed them,
while retaining the current checks for all other callers. Pass that option at
the createConversation call site after the validation block.

In `@apps/web/src/app/api/ai/chat/stream-join/`[messageId]/route.ts:
- Around line 44-62: Extract the duplicated page-access expression into a shared
helper near hasViewAccess, accepting channelOwner, userId, and meta.pageId as
needed. Replace both the hasPageAccess assignment and the pageOk computation
inside hasViewAccess with calls to this helper, preserving the existing
authorization outcomes and recheck behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 86c86eb5-1435-4e83-8b02-143888a742d0

📥 Commits

Reviewing files that changed from the base of the PR and between f4980cc and 1f62560.

📒 Files selected for processing (46)
  • apps/web/src/app/api/ai/chat/__tests__/stream-socket-events.test.ts
  • apps/web/src/app/api/ai/chat/active-streams/__tests__/route.test.ts
  • apps/web/src/app/api/ai/chat/active-streams/route.ts
  • apps/web/src/app/api/ai/chat/route.ts
  • apps/web/src/app/api/ai/chat/stream-join/[messageId]/__tests__/route.test.ts
  • apps/web/src/app/api/ai/chat/stream-join/[messageId]/route.ts
  • apps/web/src/app/api/ai/global/[id]/messages/__tests__/credit-gate.test.ts
  • apps/web/src/app/api/ai/global/[id]/messages/__tests__/stream-socket-events.test.ts
  • apps/web/src/app/api/ai/global/[id]/messages/route.ts
  • apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx
  • apps/web/src/components/layout/middle-content/page-views/ai-page/__tests__/AiChatView.test.tsx
  • apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx
  • apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx
  • apps/web/src/contexts/GlobalChatContext.tsx
  • apps/web/src/contexts/__tests__/GlobalChatContext.test.tsx
  • apps/web/src/hooks/__tests__/useAgentChannelMultiplayer.test.ts
  • apps/web/src/hooks/__tests__/useAppStateRecovery.test.ts
  • apps/web/src/hooks/__tests__/useChannelStreamSocket.test.ts
  • apps/web/src/hooks/useAgentChannelMultiplayer.ts
  • apps/web/src/hooks/useAppStateRecovery.ts
  • apps/web/src/hooks/useChannelStreamSocket.ts
  • apps/web/src/lib/ai/core/__tests__/stream-abort-client.test.ts
  • apps/web/src/lib/ai/core/__tests__/stream-lifecycle.test.ts
  • apps/web/src/lib/ai/core/__tests__/stream-liveness.test.ts
  • apps/web/src/lib/ai/core/__tests__/stream-subscription-authz.test.ts
  • apps/web/src/lib/ai/core/__tests__/stream-takeover.test.ts
  • apps/web/src/lib/ai/core/stream-abort-client.ts
  • apps/web/src/lib/ai/core/stream-lifecycle.ts
  • apps/web/src/lib/ai/core/stream-liveness.ts
  • apps/web/src/lib/ai/core/stream-subscription-authz.ts
  • apps/web/src/lib/ai/core/stream-takeover.ts
  • apps/web/src/lib/ai/shared/hooks/index.ts
  • apps/web/src/lib/ai/shared/hooks/useChatTransport.ts
  • apps/web/src/lib/ai/shared/hooks/useConversationIdentity.ts
  • apps/web/src/lib/ai/streams/__tests__/consumingChannels.test.ts
  • apps/web/src/lib/ai/streams/__tests__/isPlaceholderConversationId.test.ts
  • apps/web/src/lib/ai/streams/__tests__/shouldAttachStream.test.ts
  • apps/web/src/lib/ai/streams/consumingChannels.ts
  • apps/web/src/lib/ai/streams/isPlaceholderConversationId.ts
  • apps/web/src/lib/ai/streams/shouldAttachStream.ts
  • apps/web/src/lib/repositories/conversation-repository.ts
  • apps/web/src/lib/websocket/socket-utils.ts
  • packages/db/drizzle/0200_strange_viper.sql
  • packages/db/drizzle/meta/0200_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/schema/ai-streams.ts
💤 Files with no reviewable changes (2)
  • apps/web/src/lib/ai/streams/isPlaceholderConversationId.ts
  • apps/web/src/lib/ai/streams/tests/isPlaceholderConversationId.test.ts

Comment thread apps/web/src/hooks/useChannelStreamSocket.ts
Comment thread apps/web/src/hooks/useChannelStreamSocket.ts
Comment thread apps/web/src/lib/ai/core/__tests__/stream-lifecycle.test.ts Outdated
@2witstudios

Copy link
Copy Markdown
Owner Author

@chatgpt-codex-connector — good catch on the stale docs, and you were right that they were the inverse of the code. Landed in e3d4c1a (I didn't pull your sandbox commit; it isn't reachable from this repo).

Both comments you flagged had drifted during the fixes:

  • stream-takeover.ts still said "live ones are aborted, and every row is reconciled". It's now the opposite on both halves: every row gets an abort attempt (liveness must never gate an abort — skipping one for a row we misjudged leaves a real generation running), and only rows we can prove are finished get reconciled (a live row we couldn't abort is left alone rather than being stamped aborted while it keeps generating).
  • stream-liveness.ts still said the heartbeat was "bumped from the existing parts-checkpoint write". It's a dedicated 20s timer now — a quiet tool call pushes no parts at all, so no checkpoint-driven heartbeat could ever have covered it.

Two more staleness bugs in the same comments while I was there: the takeover guard is no longer POST /api/ai/chat only (the global chat route calls it too — that was itself a gap this loop found), and the constant referenced by name was STREAM_HEARTBEAT_INTERVAL_MS rather than the actual HEARTBEAT_INTERVAL_MS.

Re: your test-run warning — bun test won't resolve this app's aliases/deps. The suites run under vitest from apps/web (bun run --filter web test); all four you tried pass there, and CI's Unit Tests job is green.

@chatgpt-codex-connector

Copy link
Copy Markdown

Summary

Testing

  • bun run --filter web test src/lib/ai/shared/hooks/__tests__/useConversationIdentity.test.ts src/hooks/__tests__/useChannelStreamSocket.test.ts src/hooks/__tests__/useAgentChannelMultiplayer.test.ts
  • bun run --filter web lint — passed with existing warnings.
  • ⚠️ bun run --filter web typecheck — warning due to sandbox/workspace module-resolution limitations: the command failed broadly on unresolved @pagespace/db/* subpath imports and unrelated existing type errors.

View task →

@2witstudios

Copy link
Copy Markdown
Owner Author

@chatgpt-codex-connector — thanks, this landed on a stale SHA (a5ffdab) so some of it had already been fixed independently, but one finding was new and real, and it was mine.

Adopted: the stale resolve clobbering isPersisted (c4f9e2b)

You're right, and this was a genuine hole. The reducer already drops a stale RESOLVED — its own comment says "a stale resolve must never clobber an identity the user has already set more recently" — but isPersisted is state I added outside the reducer, and setIsPersisted() ran unconditionally, before the dispatch the reducer would have discarded.

The failure is nastier than a flicker: the user picks a conversation from History before the initial resolve lands, the reducer correctly keeps their choice, and then the late resolve flips isPersisted to false — so the loaders skip a conversation they now believe has no server-side rows. The user stares at an empty chat for a real conversation with real history. Fixed with a generation counter bumped on both resolve-start and setIdentity, extending the reducer's existing rule to the state that escaped it. Test fails without it.

Already fixed, independently (different SHA)

  • Stop-slot scoping — claimed by messageId in both useAgentChannelMultiplayer and GlobalChatContext. Worth flagging that this fix has a trap I hit: scoping the release by messageId removes the accidental recovery the old boolean gave you. The sidebar swaps agents in place, and useChannelStreamSocket tears down on that channelId change without firing onOwnStreamFinalize — so a []-keyed cleanup never runs, the claim survives, the next agent's stream declines the occupied slot, and its finalize doesn't match the stale id. Net result: stuck isAgentStreaming with a Stop button wired to the previous agent's dead message. The cleanup has to be keyed on [channelId].
  • Failed-join completion — handled via explicit { joinFailed } metadata on onStreamComplete rather than making consumers infer intent from an absent store entry.
  • stream-join page-access helper — the check is now split: a genuine page-access failure still 403s and audits; a stream the caller simply may not subscribe to (a co-member's private conversation) is a quiet 404, because that's the ordinary consequence of a page-wide broadcast, not an attack.

Handled differently: the conversation-list sync guard

I didn't take "only mark synced once the list actually contains the id" — that removes the latch, so a conversation that legitimately never appears (another member's private one, which listConversations can never return) would re-refresh on every list change. Instead the sync retries once at stream completion, when the row certainly exists, and is scoped to the conversation on screen. Bounded, and it can't be driven by remote traffic.

All checks green; the 3 remaining web suite failures are pre-existing (verified identical on a clean origin/master worktree).

@chatgpt-codex-connector

Copy link
Copy Markdown

Summary

Testing

  • ⚠️ bun run --filter web test src/lib/ai/shared/hooks/__tests__/useConversationIdentity.test.ts — warning due to missing workspace dependencies in this checkout: vitest: command not found.
  • git status --short — working tree was clean.

View task →

@2witstudios

Copy link
Copy Markdown
Owner Author

@chatgpt-codex-connector — confirmed, thanks for the verification pass. Nothing further needed on that one.

For completeness, since you last looked the branch also picked up two things worth knowing:

  • Merged current master. fix(mobile): always show hover-revealed controls on every touch device #2009 and fix(ios): unbrick the iOS shell — black screen + external-browser login lockout #2010 landed, and master claimed migration slots 0200/0201 — so the heartbeat migration was regenerated as 0202 (byte-identical content: ADD COLUMN last_heartbeat_at + the (conversation_id, status) index). The PR was briefly CONFLICTING; it's MERGEABLE | CLEAN again and all 10 checks are green.
  • A follow-on bug in my own stop-slot fix. Scoping the release by messageId (which you and CodeRabbit both asked for, correctly) removed an accidental recovery the old boolean provided, and re-keying the cleanup to [channelId] to restore it then created a second hazard: the slot is a shared singleton that GlobalAssistantView writes directly, outside the claim protocol — so a stale claim could null a Stop button that now belongs to another surface. The release is now guarded on the slot still holding our stop function. Both hazards are pinned by tests that fail without the guard.

@chatgpt-codex-connector

Copy link
Copy Markdown

Summary

  • Acknowledged the trigger: no further action is needed on this PR follow-up.
  • Confirmed the working tree is clean and no code changes were made.
  • Did not create a commit or call the PR tool, because there were no changes to commit.

Testing / Checks

  • pwd && find .. -name AGENTS.md -print && git status --short && git log -1 --oneline — confirmed repo path, located AGENTS.md, verified no uncommitted changes, and confirmed current HEAD is e30e43d fix(ai): release the stop-slot only if it is still ours.

View task →

@2witstudios

Copy link
Copy Markdown
Owner Author

⚠️ Reviewer decision needed: the stop-slot machine

Flagging a pattern, because it's the most important thing a human should weigh before merging.

Seven adversarial review rounds have produced seven bugs in one small piece of state — the shared isStreaming / stopStreaming slot. Each round's fix exposed or introduced the next:

# Bug Consequence
1 Blanket own-session skip Reloaded tab drops its own stream (the PR's headline bug)
2 Ownership guard conflated two claims isStreaming stranded true — composer bricked until reload
3 Updater form passed to a zustand value setter Stop button silently did nothing while the stream kept billing
4 Claim released only by an event that couldn't fire Permanent strand after a socket-instance swap
5 Reconciliation trusted /active-streams as "what's alive" It means "started in the last 10 min" — released Stop on a live stream
6 Three lifetime horizons disagreed (10 min vs 60 min) Live stream you can't join and can't stop
7 GlobalAssistantView never joined the claim protocol Wiped the slot from outside — the root cause rounds 2–6 kept accommodating

All seven are fixed, and each is pinned by a test I verified fails without the fix (I reverted every fix to confirm the test catches the bug rather than assuming). I also retyped the slot so bug 3's class is now a compile error — reintroducing it fails tsc:

error TS2345: Argument of type '() => () => Promise<void>' is not assignable
to parameter of type '() => void | Promise<void>'.

But round 7 finding the root cause means rounds 2–6 were treating symptoms. That's a design smell, not a testing gap: the slot is a mutable singleton written by four surfaces with no ownership discipline. It pre-dates this PR — but this PR is the first thing to lean on it hard (a bootstrapped stream now claims Stop across a reload, which nothing previously did), which is why it started shedding bugs.

Three options — I'd like a human call

  1. Ship as-is. Strictly better than master; file a follow-up to redesign the slot.
  2. Redesign the slot in this PR. Single writer + explicit handoff. Bigger diff, more risk.
  3. Split. Merge the server-owned-streams core (takeover, subscription authz, sentinel removal, heartbeat — the actual ACs, all well-tested and independently valuable) and move the Stop-button/ownership work to its own PR.

My recommendation is (3). The core is what the PR was for; the stop-slot machine is a pre-existing landmine this PR merely walked onto, and it deserves review on its own terms rather than being the tail wagging this one.

Happy to execute any of the three — just say which. Meanwhile I'm continuing to converge everything else.

@2witstudios

Copy link
Copy Markdown
Owner Author

Update: bug 8 found, and it forced the design fix

Following the decision request above — an eighth pass found a bug that is not a race and needs no refresh. It is reachable by clicking, and it settles the question of which option to take.

GlobalAssistantView never unmounts. CenterPanel only hides it, so after one dashboard visit it stays mounted with its effects running on every page. SidebarChatTab is mounted exactly when GAV is hidden. So the two are co-mounted almost always — and they do not share an agent: GAV's comes from usePageAgentDashboardStore, the sidebar's from useSidebarAgentStore (independent, localStorage-persisted).

They shared one identity-less slot: a bare isAgentStreaming: boolean and a bare stop function. A boolean cannot express whose, so it lied:

  1. Dashboard, agent B, send a long prompt → slot holds B's stop, flag true.
  2. Click a document. GAV is hidden but still mounted, still streaming.
  3. Sidebar shows its own agent A — and reads the slot unscoped.
  4. Sidebar renders Stop and disables its composer for a stream that isn't its own.
  5. Clicking it aborts B. And if the sidebar had its own stream in flight, that branch is taken instead of its fallback — so A is never stopped, and keeps generating and keeps billing. The exact failure this PR exists to eliminate, reached from the other end.

This is the redesign, and it was the right call

Seven rounds hardened the claim protocol; none asked what the slot means. So it now carries its owner:

streamingAgentId: string | null
agentStopSlot: { agentId, stop } | null

Every read goes through selectIsAgentStreaming(myAgentId) / selectAgentStop(myAgentId). A reader must name the agent it is asking about, and a slot belonging to someone else simply doesn't answer. The type change made the compiler enumerate every call site rather than leaving me to find them — which is the property the old design lacked and the reason it shed eight bugs.

So: option 2, scoped tightly. Not a speculative rewrite — the minimum change that makes the bug class unrepresentable. The diff is contained (one store, two consumers, one predicate).

Two things worth saying plainly:

  • The migration caught a bug in itself. The release ran against the current channelId, but a cleanup fired by an agent switch has already moved on — so it cleared the wrong agent's flag and stranded the old one. Fixed to release against the agent it actually claimed for.
  • My first regression test passed with the bug reintroduced. The helpers reimplemented the agent check instead of calling the production selectors, so it proved nothing. Rewritten to exercise the shipped code; it now fails without the fix (verified by reverting).

Verification: tsc clean · lint 14/14 · 12,645 web tests passing. The 3 failing suites remain the pre-existing environment ones (missing Postgres role + one timezone test), reproduced identically on clean origin/master.

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

Actionable comments posted: 3

Caution

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

⚠️ Outside diff range comments (2)
apps/web/src/hooks/useChannelStreamSocket.ts (2)

278-291: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not finalize an own stream solely because its SSE join failed.

A cross-instance join failure is explicitly treated as a still-live stream, but fireOwnFinalize() immediately releases its Stop claim. Keep the claim until chat:stream_complete or an authoritative active-stream snapshot excludes the message.

Proposed fix
           if (skipReplayCount === 0) {
             removeStream(messageId);
           }
-          fireOwnFinalize(messageId);
+          // A failed local join does not establish server-side completion.
+          // Release ownership on stream completion or snapshot reconciliation.
🤖 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/hooks/useChannelStreamSocket.ts` around lines 278 - 291, Remove
the fireOwnFinalize call from the SSE join-failure path in the stream handling
logic, including the skipReplayCount branches. Preserve the Stop claim after a
failed join, and release it only when chat:stream_complete arrives or an
authoritative active-stream snapshot excludes the message.

309-372: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Prevent superseded bootstraps from attaching stale streams.

The generation check only suppresses onActiveStreamsSnapshot. An older response arriving after a newer snapshot can still add a stale stream, invoke onOwnStreamBootstrap, and resurrect a released Stop claim. Gate row processing on generation too, or track the latest successfully applied bootstrap generation.

🤖 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/hooks/useChannelStreamSocket.ts` around lines 309 - 372, The
runBootstrap row-processing loop must ignore responses from superseded bootstrap
generations, not just suppress onActiveStreamsSnapshot. After the fetch/JSON
awaits and before iterating data.streams, verify generation ===
bootstrapGeneration and return when stale, preventing addStream,
onOwnStreamBootstrapRef, and startConsume from attaching outdated streams or
restoring released claims.
🤖 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.

Inline comments:
In `@apps/web/src/contexts/GlobalChatContext.tsx`:
- Around line 279-313: Scope the bootstrap Stop-slot claim to both conversation
identity and message identity, adding a claimed conversation ID alongside
claimedStopMessageIdRef and validating both when releasing or reconciling. When
activeId resolves from null, release any claim belonging to a different
conversation and rejoin/reconcile the slot for the resolved conversation instead
of retaining the unscoped bootstrap. Update releaseStopSlotIfStillOurs and the
related claim/reconciliation logic around the additional range to use the
conversation ID consistently.

In `@apps/web/src/hooks/useAgentChannelMultiplayer.ts`:
- Around line 103-109: Implement a reclaim handoff for declined live streams in
the agent-slot ownership flow used by useAgentChannelMultiplayer: subscribe to
the active agent/conversation slot and retry claiming it when the current owner
releases it, but only while this surface’s own stream remains in flight. Ensure
the reclaimed owner restores the Stop control and cleanly unsubscribes or stops
retrying when the stream ends, the component unmounts, or the scope changes.

In `@apps/web/src/stores/page-agents/usePageAgentDashboardStore.ts`:
- Around line 62-94: The streaming state contract currently keys ownership only
by agent, so concurrent conversations for the same agent overwrite each other.
Update the store fields, writers, and selectors—including selectIsAgentStreaming
and selectAgentStop and the referenced stream lifecycle logic—to key entries by
both conversationId and agentId, ensuring stop callbacks and cleanup affect only
the matching conversation while preserving independent concurrent streams.

---

Outside diff comments:
In `@apps/web/src/hooks/useChannelStreamSocket.ts`:
- Around line 278-291: Remove the fireOwnFinalize call from the SSE join-failure
path in the stream handling logic, including the skipReplayCount branches.
Preserve the Stop claim after a failed join, and release it only when
chat:stream_complete arrives or an authoritative active-stream snapshot excludes
the message.
- Around line 309-372: The runBootstrap row-processing loop must ignore
responses from superseded bootstrap generations, not just suppress
onActiveStreamsSnapshot. After the fetch/JSON awaits and before iterating
data.streams, verify generation === bootstrapGeneration and return when stale,
preventing addStream, onOwnStreamBootstrapRef, and startConsume from attaching
outdated streams or restoring released claims.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c35bd039-82be-4b0c-8063-a15d508cf57f

📥 Commits

Reviewing files that changed from the base of the PR and between 1f62560 and 661de38.

📒 Files selected for processing (29)
  • apps/web/src/app/api/ai/chat/active-streams/__tests__/route.test.ts
  • apps/web/src/app/api/ai/chat/active-streams/route.ts
  • apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx
  • apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx
  • apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx
  • apps/web/src/contexts/GlobalChatContext.tsx
  • apps/web/src/contexts/__tests__/GlobalChatContext.test.tsx
  • apps/web/src/hooks/__tests__/useAgentChannelMultiplayer.test.ts
  • apps/web/src/hooks/__tests__/useChannelStreamSocket.test.ts
  • apps/web/src/hooks/useAgentChannelMultiplayer.ts
  • apps/web/src/hooks/useChannelStreamSocket.ts
  • apps/web/src/lib/ai/core/__tests__/stream-lifecycle.test.ts
  • apps/web/src/lib/ai/core/__tests__/stream-multicast-registry.test.ts
  • apps/web/src/lib/ai/core/stream-abort-registry.ts
  • apps/web/src/lib/ai/core/stream-horizons.ts
  • apps/web/src/lib/ai/core/stream-lifecycle.ts
  • apps/web/src/lib/ai/core/stream-liveness.ts
  • apps/web/src/lib/ai/core/stream-multicast-registry.ts
  • apps/web/src/lib/ai/core/stream-takeover.ts
  • apps/web/src/lib/ai/shared/hooks/__tests__/useConversationIdentity.test.ts
  • apps/web/src/lib/ai/shared/hooks/useConversationIdentity.ts
  • apps/web/src/lib/ai/streams/__tests__/shouldClaimAgentStopSlot.test.ts
  • apps/web/src/lib/ai/streams/shouldClaimAgentStopSlot.ts
  • apps/web/src/stores/page-agents/index.ts
  • apps/web/src/stores/page-agents/usePageAgentDashboardStore.ts
  • packages/db/drizzle/0203_adorable_amphibian.sql
  • packages/db/drizzle/meta/0203_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/schema/ai-streams.ts
✅ Files skipped from review due to trivial changes (3)
  • packages/db/drizzle/0203_adorable_amphibian.sql
  • packages/db/drizzle/meta/_journal.json
  • packages/db/drizzle/meta/0203_snapshot.json
🚧 Files skipped from review as they are similar to previous changes (10)
  • packages/db/src/schema/ai-streams.ts
  • apps/web/src/app/api/ai/chat/active-streams/tests/route.test.ts
  • apps/web/src/lib/ai/core/stream-takeover.ts
  • apps/web/src/lib/ai/core/tests/stream-lifecycle.test.ts
  • apps/web/src/hooks/tests/useChannelStreamSocket.test.ts
  • apps/web/src/lib/ai/core/stream-lifecycle.ts
  • apps/web/src/contexts/tests/GlobalChatContext.test.tsx
  • apps/web/src/lib/ai/core/stream-liveness.ts
  • apps/web/src/lib/ai/shared/hooks/useConversationIdentity.ts
  • apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx

Comment thread apps/web/src/contexts/GlobalChatContext.tsx
Comment thread apps/web/src/hooks/useAgentChannelMultiplayer.ts
Comment thread apps/web/src/stores/page-agents/usePageAgentDashboardStore.ts

@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/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx (1)

1094-1130: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add isActuallyStreaming to handleStop’s deps

handleStop reads isActuallyStreaming but only depends on isStreaming and lastAssistantMessageId. That can leave it using a stale 'submitted' vs 'streaming' decision and falling back to the wrong abort path.

🤖 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/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx`
around lines 1094 - 1130, Add isActuallyStreaming to the dependency array of
handleStop so resolveActiveAssistantMessageId uses the current streaming state.
Keep the existing stop and abort logic unchanged.
🤖 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/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx`:
- Around line 1094-1130: Add isActuallyStreaming to the dependency array of
handleStop so resolveActiveAssistantMessageId uses the current streaming state.
Keep the existing stop and abort logic unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5b5d9c5c-4d69-490c-93ef-c7e9f171f9b6

📥 Commits

Reviewing files that changed from the base of the PR and between deed0b7 and d223afe.

📒 Files selected for processing (14)
  • apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx
  • apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx
  • apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx
  • apps/web/src/contexts/GlobalChatContext.tsx
  • apps/web/src/contexts/__tests__/GlobalChatContext.test.tsx
  • apps/web/src/lib/ai/core/__tests__/stream-abort-client.test.ts
  • apps/web/src/lib/ai/core/__tests__/stream-lifecycle.test.ts
  • apps/web/src/lib/ai/core/stream-abort-client.ts
  • apps/web/src/lib/ai/core/stream-lifecycle.ts
  • apps/web/src/lib/ai/shared/hooks/useChatTransport.ts
  • apps/web/src/lib/ai/streams/__tests__/holdForStream.test.ts
  • apps/web/src/lib/ai/streams/__tests__/shouldClaimGlobalStopSlot.test.ts
  • apps/web/src/lib/ai/streams/resolveActiveAssistantMessageId.ts
  • apps/web/src/lib/ai/streams/shouldClaimGlobalStopSlot.ts
✅ Files skipped from review due to trivial changes (1)
  • apps/web/src/lib/ai/streams/resolveActiveAssistantMessageId.ts
🚧 Files skipped from review as they are similar to previous changes (7)
  • apps/web/src/lib/ai/streams/tests/holdForStream.test.ts
  • apps/web/src/lib/ai/core/tests/stream-abort-client.test.ts
  • apps/web/src/lib/ai/core/stream-lifecycle.ts
  • apps/web/src/lib/ai/core/tests/stream-lifecycle.test.ts
  • apps/web/src/contexts/GlobalChatContext.tsx
  • apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx
  • apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx

@2witstudios

Copy link
Copy Markdown
Owner Author

Round 14: seven more bugs. Two of them were mine. One I introduced last round.

Two independent adversarial passes (one client, one server). Both found real defects. Fixed and pushed:

# What Where it came from
1 Stop aborted the PREVIOUS turn's message, on every turn after the first My regression, last round
2 The heartbeat cap did not close the hole it named — the parts checkpoint refreshed liveness with no deadline, and wiped the crash-recovery snapshot Mine, in this PR
3 Transport tracking keys frozen at construction — abort was a guaranteed map miss after any conversation switch Pre-existing
4 GlobalAssistantView freed GlobalChatContext's activeStreams key; the context freed nothing Pre-existing
5 Sidebar registered sidebar:<id> and cleared <id> — leaked its own, deleted the dashboard's Pre-existing
6 Legacy-conversation ownership checked per-page → attacker squats a victim's conversation from their own page → victim permanently 403'd out of their own history Squat pre-existing; the lockout is new in this PR
7 Global stop slot claimed with no free-slot guard, unlike its agent twin Pre-existing

Every one is pinned by a test I verified fails without the fix.

The one that should worry you

#1 was mine, and it was a regression I introduced while fixing something else. Verified against the vendored SDK, not assumed: useChat sets status: 'submitted' before the request and pushes the new assistant message only inside write(), which flips to 'streaming' in the same job. So during the whole submitted window, the messages array's last assistant message is the previous turn's reply. holdForStream latches on the first live render — a submitted render — so it captured and held a message that finished minutes ago.

Stop then aborted an id the registry no longer knew. The local fetch stopped, the button looked like it worked, and the server kept generating, kept running write tools, and kept billing. On every turn after the first.

Last round I hoisted the held id into the primary Stop button. That is what promoted this from a rare failure to a per-turn one. I made it worse, and only an independent adversarial pass caught it.

Known residual, deliberately NOT fixed here

TOCTOU on the takeover guard. takeOverConversationStreams does a plain SELECT, and the row that would block a peer is only INSERTed a few statements later. Two near-simultaneous sends on one conversation can both see zero in-flight rows and both proceed — two generations, two bills. The guard's own docblock claims this cannot happen.

I am not fixing it in this PR. It needs DB-level serialization (pg_advisory_xact_lock spanning takeover+insert, or a partial unique index on (conversation_id) WHERE status='streaming' — whose migration would fail outright on any existing duplicate rows and so needs a reconciliation step first). That is an invasive change at the tail of an already-overloaded PR, and master has no takeover at all — concurrent sends there always double-generate. This PR is a strict improvement that leaves a narrow residual race. Shipping a risky migration to close it now is how I would create bug #28.

It should be its own PR, with its own review.

Where this leaves us

Twenty-six bugs, fourteen rounds, one subsystem. The rate is not falling, and this round it was me generating them.

The server-owned-streams core — takeover, subscription authz (a real IDOR and a real privacy leak, both closed), sentinel removal recovering stranded history with no migration, the heartbeat — is stable and has been for many rounds. Every new bug for the last nine rounds has come from the Stop-button/ownership machine, which is pre-existing and orthogonal to all seven acceptance criteria.

I have asked five times to split this PR. Asking again, plainly: merge the core; give the ownership machine its own PR. It is a subsystem with a defect density that has now defeated fourteen review rounds, two of them mine, and it deserves a focused review rather than a ride-along.

I will keep converging if that is the call. But I am not going to tell you it is clean. I have no evidence for that, and the last time I asserted something I had not verified — telling CodeRabbit a thread was "dissolved" — I was wrong about that too.

CI green (lint/typecheck, unit, CodeQL, security suite, secret scanning, dependency audit). MERGEABLE, no conflicts. 12,675 tests passing; 3 pre-existing failures (a Postgres role absent from this environment plus one timezone-dependent test), reproduced identically on clean master.

@2witstudios 2witstudios changed the base branch from master to pu/server-owned-streams-core July 12, 2026 05:40
@2witstudios 2witstudios changed the title feat(ai): server-owned streams — clients become pure subscribers fix(ai): Stop-button / stream-ownership machine (stacked on #2018) Jul 12, 2026
@2witstudios 2witstudios force-pushed the pu/server-owned-streams branch 2 times, most recently from b19a7b3 to f9049c7 Compare July 12, 2026 05:55
Stacked on the server-owned-streams core. This is the subsystem that produced 26+ defects
across fourteen review rounds of the combined PR — two of them mine — and it is split out so
it gets the focused review that record demands.

Every bug in it has been the same bug: A PIECE OF STATE THAT NAMED THE SURFACE WHEN IT
NEEDED TO NAME THE STREAM. A chatId, a panel, a tab, the live `messages` array — used where
an (agentId, conversationId, messageId) triple was required.

Streams deliberately survive a client disconnect. So a Stop that misses is not cosmetic: it
is a runaway agent that keeps running write tools and keeps billing.

  holdForStream          capture a stream's identity when it starts, hold it until it ends
  selectLiveAssistantIds resolve EACH chat's live assistant id from ITS OWN status/messages
  shouldClaim*StopSlot   single-writer guards on the shared stop slots
  + the surfaces and the page-agents store, rekeyed by (agentId, conversationId)

THE THREE THAT MATTER

1. Stop aborted the PREVIOUS turn's message, on every turn after the first. useChat sets
   status='submitted' BEFORE issuing the request and pushes the new assistant message only
   inside write(), which flips to 'streaming' in the same job — so during submitted, the
   array's last assistant message is the previous turn's reply. holdForStream latches on the
   first live render, which is a submitted render, so it captured and held a message that had
   finished minutes ago. Every messageId now resolves only at status === 'streaming'.

2. The dedup guard TRUNCATED the reply. "We have a message with this id" is not "we have its
   content": the server names the assistant message, so useChat's copy shares the stream's id
   — and useChat does not roll back on error, leaving a HALF-STREAMED message in the array on
   a network drop. That is exactly when recovery rejoins and the full reply arrives. It now
   replaces by id instead of skipping.

3. A surface may only free the activeStreams key it allocated. The sidebar registered
   `sidebar:<convId>` and cleared the bare `<convId>` — a key it never writes, and which
   belongs to the dashboard.

WHY selectLiveAssistantIds EXISTS

My first fix for (1) passed every test with the bug still present. The defect was in the
WIRING, and a test of holdForStream is blind to which chat's id the caller hands it. So the
wiring itself became the unit under test: it takes both chats and returns both ids,
structurally unable to let one reach the other's slot.

STILL WANTED, DELIBERATELY NOT DONE HERE

Every key here is a string, correct by convention and by comment. The real fix is a branded
type the transport alone can mint, so "named the surface instead of the stream" becomes a
COMPILE ERROR rather than a bug found in round fifteen. A wide-blast-radius refactor of the
highest-defect subsystem, at the tail of a long PR, with my recent error rate, is how bug #29
gets written. It should be a deliberate follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ie3Bmn3sgiUJ7a6ErwEc4
@2witstudios 2witstudios force-pushed the pu/server-owned-streams branch from f9049c7 to 53c9c79 Compare July 12, 2026 06:10
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