[ee] fix: harden the three home-AI-chat/search P1s after deeper review

Follow-up to the previous P1 commit; sharper review found the earlier guards
insufficient:

- refreshFreeTierUsage now compares against the most-recently-*requested*
  workspace (new copilotWorkspaceRequested in aiStore, set synchronously in
  loadCopilot), not the last-*resolved* one — otherwise a warm session
  finishing while a newer workspace's load is still in flight could win the
  monotonic token and restore its stale workspace over the one being loaded.
- The content-search view is keyed by workspace ({#key $workspaceStore}) so a
  switch remounts ContentSearchInner; late in-flight responses from the
  previous workspace can no longer land in the new one's component.

Backend (EE, via ee-repo-ref bump to 03ef0eb): the free-tier reservation now
also prices the worst-case input cap (at the cache-miss rate), and
enforce_free_tier_body rejects oversized prompts and pins n=1 — so an aborted
large-prompt request can no longer dodge the input bill that reconciliation
would otherwise charge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-07-15 12:57:34 +02:00
parent aae3c95775
commit fb11fa5047
4 changed files with 22 additions and 11 deletions
+1 -1
View File
@@ -1 +1 @@
48f5e2ec3b3bb903b7c172a6e7925967a464b331
03ef0ebfad8a36a15d0af576b29e9379f87d484b
+6
View File
@@ -97,8 +97,14 @@ let loadCopilotToken = 0
// matching its committed workspace so getCurrentModel() can't read the previous
// workspace's provider/model while the scoped load is still in flight.
export const copilotWorkspace = writable<string | undefined>(undefined)
// The workspace of the most recent loadCopilot *request*, set synchronously before the
// await — as opposed to `copilotWorkspace`, which only updates once a load resolves. A
// background refresh must compare against this so it can't supersede an in-flight load for
// a newer workspace (which would otherwise win the monotonic token and restore stale state).
export const copilotWorkspaceRequested = writable<string | undefined>(undefined)
export async function loadCopilot(workspace: string) {
const token = ++loadCopilotToken
copilotWorkspaceRequested.set(workspace)
workspaceAIClients.init(workspace)
try {
const info = await WorkspaceService.getCopilotInfo({ workspace })
@@ -64,7 +64,7 @@ import { untrack } from 'svelte'
import { get } from 'svelte/store'
import { BROWSER } from 'esm-env'
import { workspaceStore, type DBSchemas } from '$lib/stores'
import { copilotInfo, copilotWorkspace, loadCopilot } from '$lib/aiStore'
import { copilotInfo, copilotWorkspaceRequested, loadCopilot } from '$lib/aiStore'
import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core'
import { readDocsPageTool, searchDocsTool } from './docs/core'
import { TypewriterReveal } from './typewriterReveal'
@@ -285,9 +285,11 @@ async function refreshFreeTierUsage(workspace: string | undefined) {
// The global copilotInfo/client are a singleton shared by every mounted session. A warm
// session can finish a turn after the user has switched to another workspace; refreshing
// then would call loadCopilot for the completing manager's (now background) workspace and
// clobber the active one's models/client/copilotWorkspace. Only refresh when the global
// state still reflects this manager's workspace.
if (get(copilotWorkspace) !== workspace) return
// clobber the active one's models/client. Compare against the most-recently-*requested*
// workspace (set synchronously), not the last-*resolved* one — otherwise a refresh could
// fire while a newer workspace's load is still in flight, win the monotonic token, and
// restore this (stale) workspace over it.
if (get(copilotWorkspaceRequested) !== workspace) return
const info = get(copilotInfo)
if (!info.freeTier || info.freeTier.exhausted) return
try {
@@ -344,14 +344,13 @@
})
// Content-filter view: reuse the Ctrl-K "Content" search (full-text, EE-gated).
// It loads its own dataset via `.open()`, then filters client-side by `search`.
// It loads its own dataset via `.open()`, then filters client-side by `search`. The
// component is keyed by workspace in the markup, so a workspace switch remounts it (this
// `bind:this` then points at the fresh instance and re-runs `open()`); the old instance is
// discarded, so its late in-flight responses can't overwrite the new workspace's results.
let contentSearchEl: ContentSearchInner | undefined = $state()
$effect(() => {
const el = contentSearchEl
// Re-run on workspace switch so content results follow the active workspace; `open()`
// reads $workspaceStore internally but under untrack (it must not re-run per keystroke),
// so depend on it explicitly here.
$workspaceStore
if (el) untrack(() => el.open())
})
@@ -859,7 +858,11 @@
contents via the indexer and shows the matching snippets; when the instance
isn't EE it renders its own warning + a limited fallback search. -->
<div class="mt-2">
<ContentSearchInner bind:this={contentSearchEl} search={filterValues.val.content} />
<!-- Keyed by workspace: switching remounts a fresh ContentSearchInner so late
responses from the previous workspace's in-flight loads can't land in it. -->
{#key $workspaceStore}
<ContentSearchInner bind:this={contentSearchEl} search={filterValues.val.content} />
{/key}
</div>
{:else if filteredItems == undefined}
<div class="mt-4"></div>