mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 00:02:13 +00:00
feat(ai-sessions): show a running session across tabs and reload finished turns (#10916)
* fix(ai-chat): make a disabled composer look disabled Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(ai-sessions): show a running session across tabs and reload finished turns Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDUsDEbycDCBTAH2x8jUAt * fix(ai-sessions): keep queued drafts through catch-up and hold locks by identity Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDUsDEbycDCBTAH2x8jUAt * fix(ai-sessions): carry pastes through refusals, spare resends and auto-resume Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDUsDEbycDCBTAH2x8jUAt * fix(ai-sessions): retry held auto-resume, keep the footer, spare bfcache freezes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDUsDEbycDCBTAH2x8jUAt * fix(ai-sessions): give each driving tab its own lock slot Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDUsDEbycDCBTAH2x8jUAt * fix(ai-sessions): release refused synthetic sends and use a text key separator Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDUsDEbycDCBTAH2x8jUAt * fix(ai-sessions): merge late-refusal restores and keep attachment-only edits Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDUsDEbycDCBTAH2x8jUAt * fix(ai-sessions): patch the stored chat pointer instead of rewriting the record Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDUsDEbycDCBTAH2x8jUAt * docs(ai-sessions): align the run-signal comments with the code Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDUsDEbycDCBTAH2x8jUAt * fix(ai-sessions): retry transient catch-up skips and gate the remaining send paths Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDUsDEbycDCBTAH2x8jUAt * docs(ai-sessions): name the chat-id seeding path persistTouched defers to Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDUsDEbycDCBTAH2x8jUAt --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4808f21b6b
commit
816dc9dcd2
@@ -44,8 +44,12 @@
|
||||
|
||||
const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
|
||||
const hasCopilot = $derived($copilotInfo.enabled)
|
||||
// Another tab is running a turn on this session: transcript stays readable,
|
||||
// composer locks, and the chat re-reads the shared record when the turn ends.
|
||||
const runHeldElsewhere = $derived(aiChatManager.runHeldElsewhere)
|
||||
const disabled = $derived(
|
||||
forceDisabled ||
|
||||
runHeldElsewhere ||
|
||||
!hasCopilot ||
|
||||
(aiChatManager.mode === AIMode.SCRIPT &&
|
||||
aiChatManager.scriptEditorOptions?.lang &&
|
||||
@@ -58,19 +62,23 @@
|
||||
const disabledMessage = $derived(
|
||||
forceDisabled
|
||||
? forceDisabledMessage
|
||||
: freeTierExhausted
|
||||
? ''
|
||||
: !hasCopilot
|
||||
? $aiUserDisabled
|
||||
? 'Windmill AI is disabled in your account settings'
|
||||
: isAdmin
|
||||
? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat`
|
||||
: 'Ask an admin to enable Windmill AI in this workspace to use this chat'
|
||||
: aiChatManager.mode === AIMode.SCRIPT &&
|
||||
aiChatManager.scriptEditorOptions?.lang &&
|
||||
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)
|
||||
? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.`
|
||||
: ''
|
||||
: runHeldElsewhere
|
||||
? // The typing indicator and the composer placeholder already carry
|
||||
// this state; a footer note would say it a third time.
|
||||
''
|
||||
: freeTierExhausted
|
||||
? ''
|
||||
: !hasCopilot
|
||||
? $aiUserDisabled
|
||||
? 'Windmill AI is disabled in your account settings'
|
||||
: isAdmin
|
||||
? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat`
|
||||
: 'Ask an admin to enable Windmill AI in this workspace to use this chat'
|
||||
: aiChatManager.mode === AIMode.SCRIPT &&
|
||||
aiChatManager.scriptEditorOptions?.lang &&
|
||||
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)
|
||||
? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.`
|
||||
: ''
|
||||
)
|
||||
|
||||
const suggestions = [
|
||||
|
||||
@@ -315,7 +315,10 @@
|
||||
}
|
||||
})
|
||||
|
||||
const showTypingIndicator = $derived(aiChatManager.loading)
|
||||
// Also shown for a run held by another tab, labeled with where it is: the
|
||||
// dots say a turn is in flight even before the reader reaches the footer
|
||||
// note. Remote runs pause nothing and offer no Stop — this tab can't cancel.
|
||||
const showTypingIndicator = $derived(aiChatManager.loading || aiChatManager.runHeldElsewhere)
|
||||
|
||||
// The manual `@` context-picker button. Shown in SCRIPT/FLOW (workspace items +
|
||||
// code blocks) and APP (datatables, frontend files). Hidden in GLOBAL — there
|
||||
@@ -571,8 +574,14 @@
|
||||
(aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false) &&
|
||||
!aiChatManager.autoAcceptEditsActive
|
||||
)
|
||||
// A disabled state with no message (a remote hold, a spent free grant) keeps
|
||||
// the footer toolbar in place — swapping it for an empty strip would make
|
||||
// the model/mode row flash out and back on every remote turn. A state with
|
||||
// a real message (archived, AI off) still shows it, hold or not, matching
|
||||
// the precedence disabledMessage itself encodes.
|
||||
const footerMessageShown = $derived(disabled && disabledMessage !== '')
|
||||
const showFooterLeftControls = $derived(
|
||||
!disabled &&
|
||||
!footerMessageShown &&
|
||||
(showContextPicker ||
|
||||
showAutonomyModeSelector ||
|
||||
(aiChatManager.mode === AIMode.SCRIPT && hasDiff))
|
||||
@@ -673,10 +682,14 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{#each pastChats as chat (chat.id)}
|
||||
<button
|
||||
class="text-left flex flex-row items-center gap-2 justify-between hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md p-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent dark:disabled:hover:bg-transparent"
|
||||
disabled={aiChatManager.loading || aiChatManager.sendInFlight}
|
||||
title={aiChatManager.loading || aiChatManager.sendInFlight
|
||||
? 'Stop the current answer to switch conversation'
|
||||
: undefined}
|
||||
disabled={aiChatManager.loading ||
|
||||
aiChatManager.sendInFlight ||
|
||||
aiChatManager.runHeldElsewhere}
|
||||
title={aiChatManager.runHeldElsewhere
|
||||
? 'Wait for the turn in the other tab to switch conversation'
|
||||
: aiChatManager.loading || aiChatManager.sendInFlight
|
||||
? 'Stop the current answer to switch conversation'
|
||||
: undefined}
|
||||
onclick={() => {
|
||||
loadPastChat(chat.id)
|
||||
close()
|
||||
@@ -706,7 +719,10 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{/snippet}
|
||||
</Popover>
|
||||
<Button
|
||||
title="New chat"
|
||||
title={aiChatManager.runHeldElsewhere
|
||||
? 'Wait for the turn in the other tab to start a new chat'
|
||||
: 'New chat'}
|
||||
disabled={aiChatManager.runHeldElsewhere}
|
||||
on:click={() => {
|
||||
saveAndClear()
|
||||
}}
|
||||
@@ -770,17 +786,19 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
)}
|
||||
>
|
||||
<ChatTypingIndicator
|
||||
loading={aiChatManager.loading}
|
||||
loading={showTypingIndicator}
|
||||
paused={waitingForUserAction}
|
||||
label={aiChatManager.loadingLabel
|
||||
? aiChatManager.loadingLabel
|
||||
: aiChatManager.compacting
|
||||
? 'Compacting conversation'
|
||||
: aiChatManager.currentReasoningActive &&
|
||||
!aiChatManager.currentReply &&
|
||||
!aiChatManager.currentReasoning
|
||||
? (aiChatManager.reasoningHiddenIndicatorLabel ?? 'Thinking')
|
||||
: undefined}
|
||||
label={aiChatManager.runHeldElsewhere
|
||||
? 'Running in another tab'
|
||||
: aiChatManager.loadingLabel
|
||||
? aiChatManager.loadingLabel
|
||||
: aiChatManager.compacting
|
||||
? 'Compacting conversation'
|
||||
: aiChatManager.currentReasoningActive &&
|
||||
!aiChatManager.currentReply &&
|
||||
!aiChatManager.currentReasoning
|
||||
? (aiChatManager.reasoningHiddenIndicatorLabel ?? 'Thinking')
|
||||
: undefined}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1104,12 +1122,12 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{#if aiChatManager.mode === AIMode.SCRIPT && hasDiff}
|
||||
{#if aiChatManager.mode === AIMode.SCRIPT && hasDiff && !disabled}
|
||||
<ChatQuickActions {askAi} {diffMode} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if disabled}
|
||||
{#if footerMessageShown}
|
||||
<div class="text-primary text-xs my-2 px-2">
|
||||
<Markdown md={disabledMessage} />
|
||||
</div>
|
||||
|
||||
@@ -129,6 +129,12 @@
|
||||
|
||||
// Generate mode-specific placeholder
|
||||
const modePlaceholder = $derived.by(() => {
|
||||
// The composer unlocks by itself when the other tab's turn ends, so the
|
||||
// placeholder names what it is waiting on (the typing indicator says
|
||||
// where the run is).
|
||||
if (aiChatManager.runHeldElsewhere) {
|
||||
return 'Waiting for the turn in the other tab to finish'
|
||||
}
|
||||
if (pendingQuestionToolCallId !== undefined) {
|
||||
return 'Answer the question above'
|
||||
}
|
||||
|
||||
@@ -501,7 +501,24 @@ export class AIChatManager {
|
||||
openRunInPreview?: (a: { jobId: string; workspace: string; label: string }) => void
|
||||
openArtifact?: (artifactId: string, name: string, version?: ArtifactVersionTarget) => void
|
||||
closeArtifact?: (artifactId: string) => void
|
||||
loading = $state<boolean>(false)
|
||||
#loading = $state<boolean>(false)
|
||||
get loading(): boolean {
|
||||
return this.#loading
|
||||
}
|
||||
// An accessor so every run bracket — the send turn, manual compaction, a
|
||||
// rollback — reports its transitions through one place, synchronously: the
|
||||
// rising edge posts the cross-tab "running here" signal the moment the
|
||||
// bracket opens (after the send's preflight awaits; the post-preflight
|
||||
// guard covers that gap), and `loading` falls only after the turn's last
|
||||
// saveChat, making the falling edge the "safe to re-read the record" signal.
|
||||
set loading(v: boolean) {
|
||||
if (v === this.#loading) return
|
||||
this.#loading = v
|
||||
this.onRunningChanged?.(v)
|
||||
}
|
||||
/** Sessions wiring (see sessionRuntime); undefined for the global
|
||||
* side-panel chat, whose transcript no other tab renders. */
|
||||
onRunningChanged: ((running: boolean) => void) | undefined = undefined
|
||||
currentReply = $state<string>('')
|
||||
currentReasoning = $state<string>('')
|
||||
currentReasoningActive = $state<boolean>(false)
|
||||
@@ -677,6 +694,14 @@ export class AIChatManager {
|
||||
// sessions modules — and re-read on every system-message rebuild; the send
|
||||
// path rebuilds after beforeSend, so a fork committed there is picked up.
|
||||
sessionContextResolver: (() => SessionPromptContext | undefined) | undefined = undefined
|
||||
// Whether another tab is running a turn on this session right now (sessions
|
||||
// wiring, same seam as above). The composer locks on it, and sendRequest
|
||||
// refuses on it — the refusal covers the send already in flight when the
|
||||
// other tab's run signal arrives, which no disabled input can stop.
|
||||
runHeldElsewhereResolver: (() => boolean) | undefined = undefined
|
||||
get runHeldElsewhere(): boolean {
|
||||
return this.runHeldElsewhereResolver?.() ?? false
|
||||
}
|
||||
// The page the side panel shows, stamped on each user message. Same seam as above:
|
||||
// a page tab is an iframe in its own realm, so the tab model is the only place the
|
||||
// chat can learn it. Undefined for a live editor — ACTIVE EDITOR covers those.
|
||||
@@ -1056,6 +1081,16 @@ export class AIChatManager {
|
||||
* doesn't spawn a new job leaves nothing to re-trigger on. A turn that DOES
|
||||
* spawn another job resumes again when that one finishes, which is the point.
|
||||
*/
|
||||
#autoResumeRetry: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
#scheduleAutoResumeRetry() {
|
||||
clearTimeout(this.#autoResumeRetry)
|
||||
this.#autoResumeRetry = setTimeout(() => {
|
||||
this.#autoResumeRetry = undefined
|
||||
void this.#maybeAutoResumeFromJobs()
|
||||
}, 5_000)
|
||||
}
|
||||
|
||||
async #maybeAutoResumeFromJobs() {
|
||||
if (this.#autoResuming) return
|
||||
// Global/sessions chat only (the only mode with a jobs tray + preamble).
|
||||
@@ -1066,6 +1101,17 @@ export class AIChatManager {
|
||||
// Nothing to continue (empty chat), or the user is mid-compose — don't
|
||||
// clobber their draft or auto-send it. Their eventual send carries the notes.
|
||||
if (this.messages.length === 0 || this.instructions.trim()) return
|
||||
// Another tab is driving: the synthetic send would only be refused, and
|
||||
// the instructions staged below would then block every later auto-resume
|
||||
// in this tab. The notes stay pending; re-checked shortly, because the
|
||||
// hold can clear silently (staleness after a driver crash) with nothing
|
||||
// else to fire this. When the driver instead ends its turn normally, its
|
||||
// own resume carries the notes and this tab's catch-up clears the local
|
||||
// copy — the re-check then finds nothing and stands down.
|
||||
if (this.runHeldElsewhere) {
|
||||
this.#scheduleAutoResumeRetry()
|
||||
return
|
||||
}
|
||||
this.#autoResuming = true
|
||||
try {
|
||||
const count = this.pendingJobNotes.length
|
||||
@@ -1104,6 +1150,8 @@ export class AIChatManager {
|
||||
// Invalidate any in-flight poll so its post-await continuation can't write
|
||||
// into the conversation we're switching to.
|
||||
this.#jobPollGeneration++
|
||||
clearTimeout(this.#autoResumeRetry)
|
||||
this.#autoResumeRetry = undefined
|
||||
this.backgroundJobs = []
|
||||
this.pendingJobNotes = []
|
||||
}
|
||||
@@ -2832,6 +2880,35 @@ export class AIChatManager {
|
||||
sendUserToast('This action needs the AI chat. Start an AI session to continue.', true)
|
||||
return
|
||||
}
|
||||
// Refused before anything mutates, so there is nothing to unwind: the
|
||||
// draft (already taken by the composer) goes back where the user can see
|
||||
// it, and the turn never starts. Only the message's own send restores it
|
||||
// — a refused queued flush is re-queued by its caller (`accepted ===
|
||||
// false`), and a copy here would double it. Paste tokens are expanded
|
||||
// into the text, as the queue does, because the restore lanes carry no
|
||||
// pastes.
|
||||
if (this.runHeldElsewhere) {
|
||||
if (options.synthetic) {
|
||||
// Client-authored prompt (a job auto-resume), not user input: nothing
|
||||
// to hand back and no toast. Releasing the staged text un-blocks the
|
||||
// next auto-resume attempt, scheduled for when the hold clears.
|
||||
this.instructions = ''
|
||||
this.#scheduleAutoResumeRetry()
|
||||
} else {
|
||||
if (!options.queued) {
|
||||
// Programmatic prompts (askAi, fix) stage their text in
|
||||
// `this.instructions` and pass no option — fall back to it so
|
||||
// they are handed back too.
|
||||
this.restoreToInput(
|
||||
expanded(chatDraft(options.instructions ?? this.instructions, options.pastes ?? [])),
|
||||
options.images,
|
||||
options.files
|
||||
)
|
||||
}
|
||||
sendUserToast('This session is running in another tab. Your message was kept.', true)
|
||||
}
|
||||
return false
|
||||
}
|
||||
this.#sendsInFlight++
|
||||
try {
|
||||
return await this.sendRequestImpl(options)
|
||||
@@ -3051,6 +3128,28 @@ export class AIChatManager {
|
||||
)
|
||||
}
|
||||
const images = modelIsBlind ? [] : requestedImages
|
||||
// Re-checks the wrapper's remote-run guard: a run announced by another tab
|
||||
// during the upkeep awaits above would otherwise interleave two turns into
|
||||
// one chat id. Resends are exempt — restartGeneration already truncated
|
||||
// the transcript, so they run as the documented advisory race instead.
|
||||
if (this.runHeldElsewhere && !options.resendReservationKey) {
|
||||
this.#releaseOutgoingReservation(reservationKey)
|
||||
if (options.synthetic) {
|
||||
// Same as the wrapper guard: an internal prompt is released, not
|
||||
// restored as a draft the user never wrote.
|
||||
this.instructions = ''
|
||||
this.#scheduleAutoResumeRetry()
|
||||
} else {
|
||||
// restoreToInput, not restoreInstructions: a draft typed during the
|
||||
// awaits above occupies the composer, and this restore must merge
|
||||
// into it (or queue), never be refused by it.
|
||||
if (!options.queued) {
|
||||
this.restoreToInput(expanded(chatDraft(this.instructions, pastes)), images, files)
|
||||
}
|
||||
sendUserToast('This session is running in another tab. Your message was kept.', true)
|
||||
}
|
||||
return false
|
||||
}
|
||||
const optimisticIndex = this.displayMessages.length
|
||||
this.loading = true
|
||||
// Create the abort controller before the (possibly slow) beforeSend pre-flight,
|
||||
@@ -3892,6 +3991,29 @@ export class AIChatManager {
|
||||
throw new Error('No user message found at the specified index')
|
||||
}
|
||||
|
||||
// Refused before anything mutates: past this point the transcript is
|
||||
// sliced and resend bytes are reserved, and the sendRequest guard could
|
||||
// only refuse AFTER that damage — restoring nothing, since this path
|
||||
// carries its text in `this.instructions`, not the options. The retry and
|
||||
// edit controls check only local `loading`, so a remote run reaches here.
|
||||
// An edit (newContent defined, even '': attachment-only edits exist) is
|
||||
// restored with its pastes expanded into the text; a bare retry mutates
|
||||
// nothing yet, so there is nothing to restore. Un-submitted context-chip
|
||||
// edits are the one loss — the chips re-seed from the untouched message
|
||||
// on the next edit.
|
||||
if (this.runHeldElsewhere) {
|
||||
if (newContent !== undefined) {
|
||||
this.restoreToInput(
|
||||
expanded(chatDraft(newContent, pastes ?? [])),
|
||||
images ?? [],
|
||||
files ?? []
|
||||
)
|
||||
}
|
||||
// "Text", not "message": chip edits are the part that does not survive.
|
||||
sendUserToast('This session is running in another tab. Your text was kept.', true)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the API restart point BEFORE reserving bytes or truncating: a
|
||||
// stale index must fail while nothing has been mutated, or the transcript
|
||||
// would be left truncated with the reservation leaked. A negative index
|
||||
@@ -4015,7 +4137,7 @@ export class AIChatManager {
|
||||
this.onChatRotated?.(this.historyManager.getCurrentChatId())
|
||||
}
|
||||
|
||||
loadPastChat = async (id: string) => {
|
||||
loadPastChat = async (id: string, { preserveQueue = false } = {}) => {
|
||||
// A turn commits into whatever transcript it finds when it ends, so swapping
|
||||
// one in underneath it misfiles the turn — or duplicates it, when the loaded
|
||||
// chat already carries the turn's own checkpoint. Gated on `sendInFlight`
|
||||
@@ -4025,7 +4147,10 @@ export class AIChatManager {
|
||||
if (chat) {
|
||||
// Drop any message queued in the current conversation so it doesn't
|
||||
// auto-send into the loaded one or linger as a card across the switch.
|
||||
this.#clearQueue()
|
||||
// `preserveQueue` is for reloads that are NOT a switch — a cross-tab
|
||||
// catch-up re-reading the conversation on screen — where the queued
|
||||
// draft is unsent user input the reload must not destroy.
|
||||
if (!preserveQueue) this.#clearQueue()
|
||||
// Stop the poller for the conversation being left before swapping in the
|
||||
// loaded chat's jobs below.
|
||||
this.clearBackgroundJobs()
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ChatCompletionMessageParam } from 'openai/resources/chat/completio
|
||||
import type { DisplayMessage } from './shared'
|
||||
import type { AttachedImage } from './imageUtils'
|
||||
import { AIChatManager, AIMode, AIAutonomyMode } from './AIChatManager.svelte'
|
||||
import { makePasteToken } from './pasteTokens'
|
||||
import { chatState } from './sharedChatState.svelte'
|
||||
import { PLAN_MODE_MESSAGES } from './planModeMessages'
|
||||
import { runChatLoop } from './chatLoop'
|
||||
@@ -3997,3 +3998,135 @@ describe('AIChatManager reasoning duration', () => {
|
||||
expect(assistantDurations(manager)).toEqual([3_000, 7_000])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AIChatManager cross-tab run seams', () => {
|
||||
// The whole cross-tab feature hangs off these two seams: `loading`'s edges
|
||||
// are the "running here" / "safe to re-read" signals, and the resolver is
|
||||
// the advisory lock. Reverting `loading` to a plain $state field would
|
||||
// silently disconnect every tab.
|
||||
it('reports loading transitions, and only transitions, through onRunningChanged', () => {
|
||||
const manager = new AIChatManager()
|
||||
const seen: boolean[] = []
|
||||
manager.onRunningChanged = (running) => seen.push(running)
|
||||
manager.loading = true
|
||||
manager.loading = true
|
||||
manager.loading = false
|
||||
manager.loading = false
|
||||
expect(seen).toEqual([true, false])
|
||||
})
|
||||
|
||||
it('refuses a send while another tab holds the run, keeping the draft', async () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.isSessionChat = true
|
||||
manager.runHeldElsewhereResolver = () => true
|
||||
|
||||
const accepted = await manager.sendRequest({ instructions: 'race loser' })
|
||||
|
||||
expect(accepted).toBe(false)
|
||||
expect(mocks.runChatLoop).not.toHaveBeenCalled()
|
||||
expect(manager.loading).toBe(false)
|
||||
// restoreToInput falls back to the queued draft when no composer is
|
||||
// mounted, so the refused text must surface there rather than vanish.
|
||||
expect(manager.queuedMessage).toBe('race loser')
|
||||
})
|
||||
|
||||
// A synthetic (auto-resume) prompt is client-authored: a refusal must
|
||||
// release it rather than hand it back as a draft the user never wrote —
|
||||
// staged instructions would otherwise block every later auto-resume.
|
||||
it('releases a refused synthetic send instead of restoring it as a draft', async () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.isSessionChat = true
|
||||
manager.runHeldElsewhereResolver = () => true
|
||||
manager.instructions = 'A background job just finished.'
|
||||
|
||||
const accepted = await manager.sendRequest({ synthetic: true })
|
||||
|
||||
expect(accepted).toBe(false)
|
||||
expect(manager.instructions).toBe('')
|
||||
expect(manager.queuedMessage).toBe('')
|
||||
})
|
||||
|
||||
// The restore lanes carry no pastes, so a refusal must expand the tokens
|
||||
// into the text — dangling markers with the content gone otherwise.
|
||||
it('expands paste tokens into the text a refusal hands back', async () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.isSessionChat = true
|
||||
manager.runHeldElsewhereResolver = () => true
|
||||
const paste = { id: 1, lines: 1, content: 'the pasted block' }
|
||||
|
||||
await manager.sendRequest({
|
||||
instructions: `see ${makePasteToken(paste)}`,
|
||||
pastes: [paste]
|
||||
})
|
||||
|
||||
expect(manager.queuedMessage).toBe('see the pasted block')
|
||||
})
|
||||
|
||||
// The wrapper's check runs before the attachment upkeep awaits; a run
|
||||
// announced by another tab during that upkeep must still be refused before
|
||||
// the turn takes visible effect.
|
||||
it('refuses a run announced by another tab during the preflight awaits', async () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.isSessionChat = true
|
||||
let held = false
|
||||
manager.runHeldElsewhereResolver = () => held
|
||||
let releaseUpkeep: (() => void) | undefined
|
||||
vi.spyOn(manager.attachedFiles, 'refreshFolders').mockImplementation(
|
||||
() => new Promise<void>((resolve) => (releaseUpkeep = resolve))
|
||||
)
|
||||
|
||||
const sending = manager.sendRequest({ instructions: 'racing turn' })
|
||||
await vi.waitFor(() => expect(manager.sendInFlight).toBe(true))
|
||||
held = true
|
||||
releaseUpkeep?.()
|
||||
|
||||
expect(await sending).toBe(false)
|
||||
expect(mocks.runChatLoop).not.toHaveBeenCalled()
|
||||
expect(manager.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a retry/edit while another tab holds the run, before mutating the transcript', async () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.isSessionChat = true
|
||||
manager.displayMessages = [
|
||||
{ role: 'user', content: 'original prompt', index: 0 },
|
||||
{ role: 'assistant', content: 'original reply' }
|
||||
] as DisplayMessage[]
|
||||
manager.messages = [
|
||||
{ role: 'user', content: 'original prompt' }
|
||||
] as ChatCompletionMessageParam[]
|
||||
manager.runHeldElsewhereResolver = () => true
|
||||
|
||||
await manager.restartGeneration(0, 'edited prompt')
|
||||
|
||||
expect(mocks.runChatLoop).not.toHaveBeenCalled()
|
||||
expect(manager.displayMessages).toHaveLength(2)
|
||||
expect(manager.messages).toHaveLength(1)
|
||||
// The edited text survives the refusal via restoreToInput's queued-draft
|
||||
// fallback.
|
||||
expect(manager.queuedMessage).toBe('edited prompt')
|
||||
})
|
||||
|
||||
// A cross-tab catch-up re-reads the conversation on screen; the queued
|
||||
// draft is unsent user input (possibly the refusal's kept message) that
|
||||
// this non-switch reload must not destroy — while a real conversation
|
||||
// switch still drops it.
|
||||
it('keeps the queued draft when a catch-up reload preserves it', async () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.isSessionChat = true
|
||||
vi.spyOn(manager.historyManager, 'loadPastChat').mockResolvedValue({
|
||||
id: 'c1',
|
||||
actualMessages: [],
|
||||
displayMessages: [],
|
||||
title: '',
|
||||
lastModified: 1
|
||||
} as never)
|
||||
|
||||
manager.queueMessage('kept across catch-up')
|
||||
await manager.loadPastChat('c1', { preserveQueue: true })
|
||||
expect(manager.queuedMessage).toBe('kept across catch-up')
|
||||
|
||||
await manager.loadPastChat('c1')
|
||||
expect(manager.queuedMessage).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -770,8 +770,17 @@
|
||||
<!-- The composer box: border + rounded live HERE (on the wrapper), not on the
|
||||
textarea, so context chips can sit INSIDE the box, above the text. The
|
||||
textarea's own @tailwindcss/forms border/ring is neutralized below. -->
|
||||
<!-- The disabled treatment lives on the wrapper for the same reason the box
|
||||
does: `disabled` on the textarea alone leaves the field looking exactly
|
||||
like a usable one, so the only cue that typing is refused is placeholder
|
||||
text the eye reads as an invitation. -->
|
||||
<div
|
||||
class="w-full scroll-pb-2 bg-surface-input rounded-md border border-border-light focus-within:border-border-selected transition-colors"
|
||||
class={twMerge(
|
||||
'w-full scroll-pb-2 rounded-md border border-border-light transition-colors',
|
||||
disabled
|
||||
? 'bg-surface-disabled cursor-not-allowed'
|
||||
: 'bg-surface-input focus-within:border-border-selected'
|
||||
)}
|
||||
>
|
||||
<!-- Context chips live inside the input box, above the textarea. The snippet
|
||||
self-guards (renders nothing when empty) so no blank row appears. -->
|
||||
@@ -825,6 +834,7 @@
|
||||
// @tailwindcss/forms border, focus ring, and background so only the
|
||||
// wrapper reads as the field.
|
||||
'!border-transparent !bg-transparent !shadow-none focus:!border-transparent focus:!ring-0',
|
||||
'disabled:cursor-not-allowed disabled:placeholder:text-disabled',
|
||||
CHAT_INPUT_PADDING,
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -599,6 +599,35 @@ export default class HistoryManager {
|
||||
}).catch((err) => console.error('Could not delete chat', err))
|
||||
}
|
||||
|
||||
/** Re-read one chat from the store into the in-memory mirror, for a record
|
||||
* another tab wrote after this manager last read it. `init()` is the wrong
|
||||
* tool: it re-reads the user's entire history to pick up a single chat.
|
||||
*
|
||||
* 'missing' is a fact about the conversation (the store holds nothing under
|
||||
* this id); 'unavailable' is a fact about this browser. Callers act on the
|
||||
* first and must not act on the second — treating a closed database as an
|
||||
* empty chat would throw away a transcript that is merely unreadable. */
|
||||
async reloadChat(id: string): Promise<'loaded' | 'missing' | 'unavailable'> {
|
||||
const db = await this.dbh.whenReady()
|
||||
if (!db) return 'unavailable'
|
||||
try {
|
||||
const chat = await db.get('chats', id)
|
||||
if (!chat) {
|
||||
// Drop the mirror too. `loadPastChat` reads from it and never from the
|
||||
// store, so a copy left behind here is a deleted chat that comes back
|
||||
// on the next rotation onto this id.
|
||||
const { [id]: _gone, ...rest } = this.savedChats
|
||||
this.savedChats = rest
|
||||
return 'missing'
|
||||
}
|
||||
this.savedChats = { ...this.savedChats, [id]: chat }
|
||||
return 'loaded'
|
||||
} catch (err) {
|
||||
console.error('Could not reload chat', err)
|
||||
return 'unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
async loadPastChat(id: string) {
|
||||
const chat = this.savedChats[id]
|
||||
if (!chat) return
|
||||
|
||||
@@ -757,3 +757,62 @@ describe('HistoryManager modified-items mask persistence', () => {
|
||||
expect(hm.getModifiedItems(id)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('HistoryManager.reloadChat', () => {
|
||||
it('picks up another tab’s write, and tells an empty chat from an unreadable store', async () => {
|
||||
const hm = new HistoryManager()
|
||||
await hm.init()
|
||||
const chatId = hm.getCurrentChatId()
|
||||
await hm.saveChat(
|
||||
[{ role: 'user', content: 'before the other tab ran' }] as DisplayMessage[],
|
||||
[] as ChatCompletionMessageParam[]
|
||||
)
|
||||
|
||||
// The other tab's turn, written straight to the store this one shares.
|
||||
const db = await openDB('copilot-chat-history::admin@test')
|
||||
const row = (await db.get('chats' as never, chatId)) as any
|
||||
row.displayMessages = [{ role: 'user', content: 'written by the driving tab' }]
|
||||
await db.put('chats' as never, row)
|
||||
db.close()
|
||||
|
||||
expect(await hm.reloadChat(chatId)).toBe('loaded')
|
||||
const chat = await hm.loadPastChat(chatId)
|
||||
expect((chat?.displayMessages[0] as any).content).toBe('written by the driving tab')
|
||||
|
||||
// A chat the store does not hold — distinct from 'unavailable' below:
|
||||
// 'missing' evicts the in-memory mirror, so conflating the two would let
|
||||
// a store that merely failed to open erase transcripts this tab holds.
|
||||
expect(await hm.reloadChat('no-such-chat')).toBe('missing')
|
||||
})
|
||||
|
||||
it('evicts the mirrored copy of a chat the driver deleted', async () => {
|
||||
const hm = new HistoryManager()
|
||||
await hm.init()
|
||||
const chatId = hm.getCurrentChatId()
|
||||
await hm.saveChat(
|
||||
[{ role: 'user', content: 'deleted by the driving tab' }] as DisplayMessage[],
|
||||
[] as ChatCompletionMessageParam[]
|
||||
)
|
||||
|
||||
const db = await openDB('copilot-chat-history::admin@test')
|
||||
await db.delete('chats' as never, chatId)
|
||||
db.close()
|
||||
|
||||
expect(await hm.reloadChat(chatId)).toBe('missing')
|
||||
// loadPastChat serves the mirror, so a copy left behind would resurrect the
|
||||
// deleted transcript the next time this id came round again.
|
||||
expect(await hm.loadPastChat(chatId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports a store it cannot open as unavailable, never as missing', async () => {
|
||||
;(globalThis as any).indexedDB = {
|
||||
open: () => {
|
||||
throw new Error('blocked')
|
||||
}
|
||||
}
|
||||
const hm = new HistoryManager()
|
||||
await hm.init()
|
||||
|
||||
expect(await hm.reloadChat(hm.getCurrentChatId())).toBe('unavailable')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -90,6 +90,15 @@ export class SessionArtifactsStore {
|
||||
await this.#load()
|
||||
}
|
||||
|
||||
/** Re-read the loaded session's artifacts from the store, for records another
|
||||
* tab wrote after this one loaded. Forces the read setSession skips: that
|
||||
* skip protects local edits whose best-effort persist failed, while a tab
|
||||
* catching up on another tab's finished turn wants the store's truth. */
|
||||
async resyncFromStore(): Promise<void> {
|
||||
if (this.#sessionId === undefined) return
|
||||
await this.#load()
|
||||
}
|
||||
|
||||
async #load(): Promise<void> {
|
||||
const token = ++this.#seq
|
||||
const id = this.#sessionId
|
||||
|
||||
@@ -29,6 +29,12 @@ import { userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { copilotWorkspace } from '$lib/aiStore'
|
||||
import { loadCopilot } from '$lib/components/copilot/loadCopilot'
|
||||
import { emptySchema, type StateStore } from '$lib/utils'
|
||||
import {
|
||||
localRunEnded,
|
||||
localRunStarted,
|
||||
onRemoteTurnEnd,
|
||||
runHeldElsewhere
|
||||
} from './sessionSync.svelte'
|
||||
import {
|
||||
commitSessionWorkspace,
|
||||
deleteSession as deleteSessionState,
|
||||
@@ -338,6 +344,15 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
// Carried into the tool helpers so this session's preview/deploy tool calls
|
||||
// dispatch to THIS session even when another session is the UI-active one.
|
||||
manager.sessionId = session.id
|
||||
// Cross-tab awareness: heartbeat while this tab runs a turn, composer lock
|
||||
// (and send refusal) while another tab does. The chat id is read at turn
|
||||
// end, not captured at start — the turn may have rotated it, and the other
|
||||
// tabs re-read whichever record it ended on.
|
||||
manager.runHeldElsewhereResolver = () => runHeldElsewhere(session.id)
|
||||
manager.onRunningChanged = (running) => {
|
||||
if (running) localRunStarted(session.id, manager.historyManager.getCurrentChatId())
|
||||
else localRunEnded(session.id, manager.historyManager.getCurrentChatId())
|
||||
}
|
||||
// The chat targets the session's OWN (possibly forked) workspace without
|
||||
// switching the global workspaceStore. Resolved live from the session record
|
||||
// so it tracks the pending → committed (and staged-fork) transitions.
|
||||
@@ -958,6 +973,65 @@ export function getRuntime(sessionId: string): SessionRuntime | undefined {
|
||||
return runtimes.get(sessionId)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-tab catch-up
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Chained per session so two turn-ends close together (a turn plus its queued
|
||||
// follow-up) re-read sequentially: the later read starts after the earlier
|
||||
// one's loadPastChat, so the newest record is what ends up on screen.
|
||||
const catchUps = new Map<string, Promise<void>>()
|
||||
|
||||
onRemoteTurnEnd((sessionId, chatId) => {
|
||||
const next = (catchUps.get(sessionId) ?? Promise.resolve())
|
||||
.then(() => applyRemoteTurnEnd(sessionId, chatId))
|
||||
.catch((e) => console.error('Failed to catch up on a turn from another tab', e))
|
||||
catchUps.set(sessionId, next)
|
||||
void next.finally(() => {
|
||||
if (catchUps.get(sessionId) === next) catchUps.delete(sessionId)
|
||||
})
|
||||
// Awaited by the caller: the composer unlock rides on this settling.
|
||||
return next
|
||||
})
|
||||
|
||||
async function applyRemoteTurnEnd(sessionId: string, chatId: string): Promise<void> {
|
||||
const runtime = runtimes.get(sessionId)
|
||||
if (!runtime) return
|
||||
const m = runtime.manager
|
||||
// Two transient states get a short retry rather than a skip, because the
|
||||
// composer unlocks when this promise settles and a skip would unlock it on
|
||||
// stale history: a send of this tab's own still in preflight (it may yet be
|
||||
// refused, leaving no turn to converge on), and a store that failed to
|
||||
// open. A turn actually running here owns the transcript instead — its own
|
||||
// end converges — and the pruner caps the whole hold at STALE_MS anyway.
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
if (m.loading) return
|
||||
if (!m.sendInFlight) {
|
||||
const res = await m.historyManager.reloadChat(chatId)
|
||||
if (res === 'missing') return
|
||||
if (res === 'loaded') break
|
||||
}
|
||||
if (attempt >= 7) return
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
if (runtimes.get(sessionId) !== runtime) return
|
||||
}
|
||||
// Disposed (session deleted, teardown) while the read was in flight.
|
||||
if (runtimes.get(sessionId) !== runtime) return
|
||||
// Adopts the driver's chat unconditionally, current view included: watching
|
||||
// a session means following where its activity is, and it is also how tabs
|
||||
// converge after an unsynced /clear rotation. A watcher browsing an older
|
||||
// conversation is pulled along — deliberate, and the price of not syncing
|
||||
// rotation as its own message.
|
||||
//
|
||||
// preserveQueue: this reload is a catch-up, not a conversation switch — a
|
||||
// draft queued here (a refused send's kept message, a failed turn's card)
|
||||
// is unsent user input the re-read must not destroy.
|
||||
await m.loadPastChat(chatId, { preserveQueue: true })
|
||||
// loadPastChat's own artifact sync no-ops for an unchanged session id, so
|
||||
// artifacts the driver wrote during the turn need this forced re-read.
|
||||
await m.artifacts.resyncFromStore()
|
||||
}
|
||||
|
||||
// Point a session's preview at a single seed tab. For re-pointing an existing
|
||||
// draft session at a new destination ("Open in AI session" / new-session-from-
|
||||
// page on a reused transient): its previous tabs — persisted with the draft
|
||||
|
||||
@@ -405,8 +405,9 @@ export function takeSessionAutoSend(sessionId: string): boolean {
|
||||
|
||||
// Persist a session on a genuine user edit, promoting an in-memory-only
|
||||
// (transient) pending session to a durable IndexedDB record on first touch.
|
||||
// Non-touch writers (runtime chatId seeding, unread watermark) call putSession
|
||||
// directly, so an untouched draft stays in memory and vanishes on reload.
|
||||
// Non-touch writers (runtime chatId seeding via patchStoredSessionChatId, the
|
||||
// unread watermark via putSession) persist directly, so an untouched draft
|
||||
// stays in memory and vanishes on reload.
|
||||
function persistTouched(s: Session): void {
|
||||
if (s.transient) delete s.transient
|
||||
s.lastActivityAt = Date.now()
|
||||
@@ -437,10 +438,10 @@ async function deleteSessionRow(db: IDBPDatabase<SessionSchema>, id: string): Pr
|
||||
await db.delete('sessions', id)
|
||||
}
|
||||
|
||||
// The one way to write a session's record, and the other half of the invariant above:
|
||||
// every caller reaches its write across an await — putSession on the DB handle, the
|
||||
// reconcile and hydrate passes on a getAll() snapshot that an interleaved delete
|
||||
// invalidates — so the tombstone has to be consulted here, not only at the entry points.
|
||||
// The way a session record is written (patchStoredSessionChatId is the one
|
||||
// exception: it re-checks the tombstone inline to stay inside its own
|
||||
// transaction). Every caller reaches its write across an await, so the
|
||||
// tombstone has to be consulted here, not only at the entry points.
|
||||
async function putSessionRow(db: IDBPDatabase<SessionSchema>, s: Session): Promise<void> {
|
||||
if (deletedSessionIds.has(s.id)) return
|
||||
await db.put('sessions', s)
|
||||
@@ -1151,7 +1152,36 @@ export function setSessionChatId(sessionId: string, chatId: string) {
|
||||
const s = sessionState.sessions.find((x) => x.id === sessionId)
|
||||
if (s && s.chatId !== chatId) {
|
||||
s.chatId = chatId
|
||||
void putSession(s)
|
||||
void patchStoredSessionChatId(s, chatId)
|
||||
}
|
||||
}
|
||||
|
||||
// Persists the pointer through the STORED row, not this tab's copy: another
|
||||
// tab may have written newer fields (summary, tabs, archive state) since this
|
||||
// tab last read the record, and a whole-object put would roll them back — a
|
||||
// watcher adopting the driver's rotation reaches here with exactly that copy.
|
||||
async function patchStoredSessionChatId(s: Session, chatId: string): Promise<void> {
|
||||
if (!BROWSER || s.transient || deletedSessionIds.has(s.id)) return
|
||||
const db = await sessionsDb.whenReady()
|
||||
if (!db) return
|
||||
try {
|
||||
const tx = db.transaction('sessions', 'readwrite')
|
||||
const stored = await tx.store.get(s.id)
|
||||
// Inline tombstone re-check in place of putSessionRow's: routing through
|
||||
// it would put outside this transaction and lose the read's atomicity.
|
||||
if (stored && !deletedSessionIds.has(s.id)) {
|
||||
stored.chatId = chatId
|
||||
await tx.store.put(stored)
|
||||
await tx.done
|
||||
return
|
||||
}
|
||||
await tx.done
|
||||
// No stored row: either the record is not yet persisted — its own
|
||||
// materialization writes it later with the chatId already set in memory —
|
||||
// or another tab deleted it, and an upsert here would resurrect it. No
|
||||
// write either way.
|
||||
} catch (e) {
|
||||
console.error('Failed to persist session chat id', e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
getSessionDraftPrompt,
|
||||
setSessionDraftPrompt,
|
||||
setSessionTabs,
|
||||
setSessionChatId,
|
||||
reconcileSessionsLifecycle,
|
||||
__resetDeletedSessionIdsForTesting,
|
||||
setSessionArchived,
|
||||
@@ -117,6 +118,30 @@ describe('sessionState IndexedDB persistence', () => {
|
||||
await vi.waitFor(() => expect(sessionState.sessions.map((s) => s.id)).toEqual(['s2', 's1']))
|
||||
})
|
||||
|
||||
// A watcher adopting the driver's chat rotation holds a stale in-memory
|
||||
// record; persisting the pointer must not roll back fields another tab
|
||||
// wrote to the store since.
|
||||
it('setSessionChatId patches the stored row instead of writing back a stale copy', async () => {
|
||||
const user = freshUser()
|
||||
await login(user)
|
||||
|
||||
const stale = session({ id: 's1', createdAt: 100, summary: 'old summary' })
|
||||
await putSession(stale)
|
||||
// A newer write from another tab, landing directly in the store.
|
||||
await putSession(session({ id: 's1', createdAt: 100, summary: 'newer summary' }))
|
||||
|
||||
sessionState.sessions = [stale]
|
||||
setSessionChatId('s1', 'chat-2')
|
||||
await flush()
|
||||
|
||||
await rehydrate(user)
|
||||
await vi.waitFor(() => {
|
||||
const s = sessionState.sessions.find((x) => x.id === 's1')
|
||||
expect(s?.chatId).toBe('chat-2')
|
||||
expect(s?.summary).toBe('newer summary')
|
||||
})
|
||||
})
|
||||
|
||||
it('does not persist a transient (untouched) session — it is in-memory only', async () => {
|
||||
const user = freshUser()
|
||||
await login(user)
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { BROWSER } from 'esm-env'
|
||||
import { SvelteMap } from 'svelte/reactivity'
|
||||
import { onUserChange, scopedKey } from '$lib/userScopedStorage'
|
||||
import { randomUUID } from '$lib/utils/uuid'
|
||||
|
||||
// Cross-tab awareness for AI sessions. Invariant: no message carries state —
|
||||
// a heartbeat is presence, turn-end triggers an idempotent re-read of the
|
||||
// shared IndexedDB record — so tabs converge on the store, never on delivery
|
||||
// order. The lock is advisory (a broadcast-latency race stays last-writer-
|
||||
// wins, as with no channel), and the channel is per-user like the stores.
|
||||
|
||||
const CHANNEL_BASE = 'windmill-sessions-sync'
|
||||
|
||||
/** Silence past STALE_MS unlocks watchers a dead driver would strand. The
|
||||
* window sits above the 1/min floor browsers throttle a hidden tab's timers
|
||||
* to — and a hidden driver is the normal case here. Only an uncleanly killed
|
||||
* tab waits it out; a closed one says goodbye via the pagehide farewell. */
|
||||
const HEARTBEAT_MS = 3_000
|
||||
const STALE_MS = 90_000
|
||||
const PRUNE_MS = 2_000
|
||||
|
||||
// `from` identifies the driving tab: two drivers racing on one session (the
|
||||
// documented advisory race) hold separate slots, so one's turn-end can never
|
||||
// unlock a watcher the other still holds.
|
||||
export type SyncMsg =
|
||||
| { kind: 'run-heartbeat'; sessionId: string; from: string }
|
||||
| { kind: 'turn-end'; sessionId: string; chatId: string; from: string }
|
||||
|
||||
/** This tab's identity on the channel (a tab never receives its own posts). */
|
||||
const TAB_ID = randomUUID()
|
||||
|
||||
// One slot per (session, driving tab), keyed with a separator no UUID contains.
|
||||
// The value is a fresh object per message: turn-end's deferred cleanup asks
|
||||
// "is this slot still mine?" by identity — a timestamp can't, since a same-
|
||||
// millisecond follow-up heartbeat would compare equal and be deleted.
|
||||
const remoteRuns = new SvelteMap<string, { at: number }>()
|
||||
|
||||
function runKey(sessionId: string, from: string): string {
|
||||
return sessionId + ':' + from
|
||||
}
|
||||
|
||||
export function runHeldElsewhere(sessionId: string): boolean {
|
||||
const prefix = sessionId + ':'
|
||||
for (const key of remoteRuns.keys()) {
|
||||
if (key.startsWith(prefix)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
let remoteTurnEnd: ((sessionId: string, chatId: string) => void | Promise<void>) | undefined
|
||||
|
||||
/** Registered by sessionRuntime, which already imports this module — a
|
||||
* callback rather than an import keeps that edge one-way. The returned
|
||||
* promise is when the catch-up has been applied; the composer stays locked
|
||||
* until it settles. */
|
||||
export function onRemoteTurnEnd(
|
||||
fn: (sessionId: string, chatId: string) => void | Promise<void>
|
||||
): void {
|
||||
remoteTurnEnd = fn
|
||||
}
|
||||
|
||||
let channel: BroadcastChannel | undefined
|
||||
let channelName: string | undefined
|
||||
|
||||
function openChannel(): void {
|
||||
const name = scopedKey(CHANNEL_BASE)
|
||||
if (name === channelName) return
|
||||
channel?.close()
|
||||
channel = undefined
|
||||
channelName = name
|
||||
if (!name) return
|
||||
try {
|
||||
const ch = new BroadcastChannel(name)
|
||||
ch.onmessage = (ev: MessageEvent<SyncMsg>) => receive(ev.data)
|
||||
channel = ch
|
||||
} catch (e) {
|
||||
// No BroadcastChannel (or blocked): every tab simply stays independent,
|
||||
// which is the pre-sync behavior rather than a broken one.
|
||||
console.error('sessionSync: could not open channel', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (BROWSER) {
|
||||
// A user switch rescopes the channel name, so the previous identity's
|
||||
// channel is closed before the next one opens.
|
||||
onUserChange(() => openChannel())
|
||||
}
|
||||
|
||||
function receive(msg: SyncMsg): void {
|
||||
switch (msg.kind) {
|
||||
case 'run-heartbeat':
|
||||
remoteRuns.set(runKey(msg.sessionId, msg.from), { at: Date.now() })
|
||||
ensurePruner()
|
||||
break
|
||||
case 'turn-end': {
|
||||
// Unlocking on receipt would let a send here start from history missing
|
||||
// the turn that just ended, so the slot holds until the catch-up
|
||||
// settles — unless the driver's next turn replaced it meanwhile (object
|
||||
// identity, see remoteRuns). The pruner caps a wedged reload at STALE_MS.
|
||||
const key = runKey(msg.sessionId, msg.from)
|
||||
const hold = { at: Date.now() }
|
||||
remoteRuns.set(key, hold)
|
||||
ensurePruner()
|
||||
Promise.resolve()
|
||||
.then(() => remoteTurnEnd?.(msg.sessionId, msg.chatId))
|
||||
.catch((e) => console.error('sessionSync: turn-end handler failed', e))
|
||||
.finally(() => {
|
||||
if (remoteRuns.get(key) === hold) remoteRuns.delete(key)
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function post(msg: SyncMsg): void {
|
||||
if (!channel) return
|
||||
try {
|
||||
channel.postMessage(msg)
|
||||
} catch (e) {
|
||||
// A failed post must never take the turn down with it.
|
||||
console.error('sessionSync: could not post message', e)
|
||||
}
|
||||
}
|
||||
|
||||
let pruneTimer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
function ensurePruner(): void {
|
||||
if (pruneTimer) return
|
||||
pruneTimer = setInterval(() => {
|
||||
const cutoff = Date.now() - STALE_MS
|
||||
for (const [id, entry] of remoteRuns) {
|
||||
if (entry.at < cutoff) remoteRuns.delete(id)
|
||||
}
|
||||
if (remoteRuns.size === 0) {
|
||||
clearInterval(pruneTimer)
|
||||
pruneTimer = undefined
|
||||
}
|
||||
}, PRUNE_MS)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Driving side
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The chat id rides along for the pagehide farewell below, which cannot ask
|
||||
// the manager for it. Taken at run start; only a mid-turn rotation could make
|
||||
// it stale, and a farewell pointing at the pre-rotation record still converges
|
||||
// (the re-read is idempotent and the next turn-end names the right one).
|
||||
const heartbeats = new Map<string, { timer: ReturnType<typeof setInterval>; chatId: string }>()
|
||||
|
||||
/** Posted when the run's loading bracket opens — after the send's attachment
|
||||
* upkeep awaits, so a competing send can start during them; the sender's own
|
||||
* post-preflight re-check is what refuses one that did. */
|
||||
export function localRunStarted(sessionId: string, chatId: string): void {
|
||||
if (heartbeats.has(sessionId)) return
|
||||
post({ kind: 'run-heartbeat', sessionId, from: TAB_ID })
|
||||
heartbeats.set(sessionId, {
|
||||
timer: setInterval(
|
||||
() => post({ kind: 'run-heartbeat', sessionId, from: TAB_ID }),
|
||||
HEARTBEAT_MS
|
||||
),
|
||||
chatId
|
||||
})
|
||||
}
|
||||
|
||||
/** `chatId` is read at turn end, not reused from the start: a rotation
|
||||
* mid-turn means the transcript now lives under a different record, and the
|
||||
* watchers' re-read must follow it there. */
|
||||
export function localRunEnded(sessionId: string, chatId: string): void {
|
||||
const entry = heartbeats.get(sessionId)
|
||||
if (entry !== undefined) {
|
||||
clearInterval(entry.timer)
|
||||
heartbeats.delete(sessionId)
|
||||
}
|
||||
post({ kind: 'turn-end', sessionId, chatId, from: TAB_ID })
|
||||
}
|
||||
|
||||
if (BROWSER) {
|
||||
// The run dies with the page: a turn-end farewell (which also has watchers
|
||||
// re-read the last checkpoint) beats making them wait out STALE_MS. Not on
|
||||
// a bfcache freeze (persisted) — that turn resumes with the page, and
|
||||
// nothing would re-arm a farewelled heartbeat.
|
||||
window.addEventListener('pagehide', (ev) => {
|
||||
if (ev.persisted) return
|
||||
for (const [sessionId, entry] of [...heartbeats]) {
|
||||
localRunEnded(sessionId, entry.chatId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Test seam: deliver a message as if it arrived on the channel. */
|
||||
export function __receiveForTest(msg: SyncMsg): void {
|
||||
receive(msg)
|
||||
}
|
||||
|
||||
/** Test seam: clear the module's state between tests. */
|
||||
export function __resetForTest(): void {
|
||||
remoteRuns.clear()
|
||||
if (pruneTimer) {
|
||||
clearInterval(pruneTimer)
|
||||
pruneTimer = undefined
|
||||
}
|
||||
for (const entry of heartbeats.values()) clearInterval(entry.timer)
|
||||
heartbeats.clear()
|
||||
remoteTurnEnd = undefined
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
__receiveForTest,
|
||||
__resetForTest,
|
||||
onRemoteTurnEnd,
|
||||
runHeldElsewhere
|
||||
} from './sessionSync.svelte'
|
||||
|
||||
// Exercises the receive-side state machine directly (the module opens no
|
||||
// BroadcastChannel outside the browser). The channel itself is glue that only
|
||||
// a real browser can prove; what these pin are the invariants a refactor could
|
||||
// silently break: the identity-token cleanup, the staleness prune, and the
|
||||
// turn-end hold.
|
||||
|
||||
beforeEach(() => {
|
||||
__resetForTest()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
__resetForTest()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('sessionSync receive-side state', () => {
|
||||
it('locks on a heartbeat and unlocks by staleness when the driver dies silently', async () => {
|
||||
__receiveForTest({ kind: 'run-heartbeat', sessionId: 's1', from: 'driver-a' })
|
||||
expect(runHeldElsewhere('s1')).toBe(true)
|
||||
|
||||
// Refreshed heartbeats keep the lock past the original entry's window.
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
__receiveForTest({ kind: 'run-heartbeat', sessionId: 's1', from: 'driver-a' })
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
expect(runHeldElsewhere('s1')).toBe(true)
|
||||
|
||||
// Silence past STALE_MS (90s) prunes the entry.
|
||||
await vi.advanceTimersByTimeAsync(40_000)
|
||||
expect(runHeldElsewhere('s1')).toBe(false)
|
||||
})
|
||||
|
||||
it('holds the lock through the turn-end catch-up and releases when it settles', async () => {
|
||||
let releaseCatchUp: (() => void) | undefined
|
||||
onRemoteTurnEnd(() => new Promise<void>((resolve) => (releaseCatchUp = resolve)))
|
||||
|
||||
__receiveForTest({ kind: 'run-heartbeat', sessionId: 's1', from: 'driver-a' })
|
||||
__receiveForTest({ kind: 'turn-end', sessionId: 's1', chatId: 'c1', from: 'driver-a' })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
// Unlocking on receipt would let a send here start from history missing
|
||||
// the turn that just ended; the lock must outlive the re-read.
|
||||
expect(runHeldElsewhere('s1')).toBe(true)
|
||||
|
||||
releaseCatchUp?.()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(runHeldElsewhere('s1')).toBe(false)
|
||||
})
|
||||
|
||||
it("keeps the lock when the driver's next turn arrives during the catch-up", async () => {
|
||||
let releaseCatchUp: (() => void) | undefined
|
||||
onRemoteTurnEnd(() => new Promise<void>((resolve) => (releaseCatchUp = resolve)))
|
||||
|
||||
__receiveForTest({ kind: 'turn-end', sessionId: 's1', chatId: 'c1', from: 'driver-a' })
|
||||
// Flush so the catch-up handler has started (releaseCatchUp is assigned)
|
||||
// before the follow-up arrives — otherwise the release below no-ops and
|
||||
// the lock would survive for the wrong reason (a catch-up that never
|
||||
// settled), passing even with the identity comparison broken.
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
// The queued-follow-up sequence: the next turn's first heartbeat lands
|
||||
// while this tab's catch-up is still reading — in the same millisecond,
|
||||
// which is why the cleanup must compare identity, not timestamps.
|
||||
__receiveForTest({ kind: 'run-heartbeat', sessionId: 's1', from: 'driver-a' })
|
||||
|
||||
releaseCatchUp?.()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(runHeldElsewhere('s1')).toBe(true)
|
||||
})
|
||||
|
||||
// Two drivers on one session is the documented advisory race; a watcher
|
||||
// must not compound it by unlocking when only one of them finishes.
|
||||
it('stays locked when one of two drivers ends its turn', async () => {
|
||||
onRemoteTurnEnd(() => Promise.resolve())
|
||||
|
||||
__receiveForTest({ kind: 'run-heartbeat', sessionId: 's1', from: 'driver-b' })
|
||||
__receiveForTest({ kind: 'turn-end', sessionId: 's1', chatId: 'c1', from: 'driver-a' })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
// Driver A's slot released with its catch-up; driver B still holds its own.
|
||||
expect(runHeldElsewhere('s1')).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user