refactor(ai-chat): lock the composer in watching tabs and own run position in one module

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-08-27 12:07:49 +02:00
co-authored by Claude Opus 5
parent 23d324c13a
commit daf7463e7a
7 changed files with 372 additions and 261 deletions
@@ -499,9 +499,8 @@
const availableAutonomyModeOptions = $derived(
autonomyModeOptions.filter((option) => option.isAvailable(autonomyAvailability))
)
// Only when this tab could hold the posture itself: the label is a promise that
// the workspace stays read-only, and the takeover in `runGuard` keeps it by
// entering plan mode here — which an unavailable mode would silently refuse.
// Only when this tab could hold the posture itself: a mode its own selector
// does not offer would read as a mode the user could switch away from here.
const showsMirroredPlan = $derived(
aiChatManager.mirroredPlanMode && aiChatManager.planModeAvailable
)
@@ -536,6 +535,17 @@
return pending?.action === 'question' ? pending.toolCallId : undefined
})
// The composer is locked while another tab's run is on screen: this tab pairs
// a mirrored transcript with the history it held before that run, and a turn
// sent from that pair would reach the model as a conversation the driver has
// already moved past. It unlocks on its own once the re-read that follows the
// turn lands. A run parked on a question is the exception — answering is the
// one thing a watching tab is there to do, and the answer travels to the
// driver instead of starting a turn here.
const composerLocked = $derived(
aiChatManager.mirroringRemoteRun && pendingQuestionToolCallId === undefined
)
// Get app context for display when in APP mode
const appContext = $derived.by((): SelectedContext | undefined => {
if (aiChatManager.mode !== AIMode.APP || !aiChatManager.appAiChatHelpers) {
@@ -819,7 +829,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{initialInstructions}
{onDraftChange}
showContext={aiChatManager.mode !== AIMode.GLOBAL}
{disabled}
disabled={disabled || composerLocked}
{pendingQuestionToolCallId}
isFirstMessage={messages.length === 0}
/>
@@ -133,6 +133,13 @@
return 'Answer the question above'
}
// Ahead of 'Ask followup': a mirrored run is almost always mid-conversation,
// so a check below that one would never be reached, and the locked composer
// would sit there inviting a followup it will not accept.
if (aiChatManager.mirroringRemoteRun) {
return 'Running in another tab'
}
if (!isFirstMessage) {
return 'Ask followup'
}
@@ -61,6 +61,7 @@ import { SvelteMap, SvelteSet } from 'svelte/reactivity'
import { createLongHash } from '$lib/editorLangUtils'
import type { AIProvider, UserDraftItemKind } from '$lib/gen'
import { maskKey } from '$lib/components/sessions/modifiedItemsMask'
import { isMirroring, runPosition } from '$lib/components/sessions/sessionRunOwner.svelte'
import { getStringError } from './utils'
import { type PasteAttachment } from './pasteTokens'
import {
@@ -694,32 +695,35 @@ export class AIChatManager {
remoteToolConfirmation: ((toolId: string, confirmed: boolean) => boolean) | undefined = undefined
remoteQuestionAnswer: ((toolId: string, choices: string[]) => boolean) | undefined = undefined
/** True while this manager is rendering a run another tab is driving. Its
* transcript then comes from mirror frames while `messages` still holds the
* pre-run history, so the pair is mismatched and must not be written to the
* shared record — the driving tab owns it until the run ends. Set by the
* session runtime. */
mirroringRemoteRun = $state(false)
/** True while this manager is showing a run another tab is driving, and on
* through the re-read that follows it. Its transcript then comes from mirror
* frames while `messages` still holds the pre-run history, so the pair is
* mismatched and must not be written to the shared record — the driving tab
* owns it until the re-read lands.
*
* Derived rather than stored: the position is what the runtime moves, and a
* second copy of it here could only ever be a copy that disagrees. A chat
* with no session (the docked copilot) is never mirroring. */
get mirroringRemoteRun(): boolean {
return isMirroring(this.sessionId)
}
/** Whether the tab driving this session was last seen in plan mode.
/** Whether the run on screen is one another tab is running in plan mode.
*
* Plan mode is the one autonomy state that is deliberately never persisted
* (see `persistAutonomyMode`): a model entered it for this session, so it
* lives only in the memory of the tab running the turn. Every other mode is
* a stored preference each tab is entitled to its own copy of.
*
* Kept after the turn ends rather than cleared with `mirroringRemoteRun`,
* because the driver stays in plan mode between turns and stops sending
* frames that could say so. The next turn's first frame corrects it; this
* tab driving one of its own clears it outright. */
mirroredPlanMode = $state(false)
* Plan mode is the one autonomy state deliberately never persisted (see
* `persistAutonomyMode`): a model entered it for this session, so it lives
* only in the memory of the tab running the turn. It is read from the run's
* position for the same reason it is not stored there — the posture belongs
* to that run, and must not outlive it into a turn this tab drives. */
get mirroredPlanMode(): boolean {
const position = runPosition(this.sessionId)
return position.state === 'watching' && position.planMode
}
/** Whether the turn that just finished is one a follow-up should be sent
* after: it committed, or the user deliberately stopped it. False through a
* provider error, an empty-response rollback, or a programmatic cancel — the
* states this manager deliberately keeps its own queued message through. The
* session runtime reports it to the other tabs, whose queues follow the same
* rule. */
* states this manager deliberately keeps its own queued message through. */
lastTurnAcceptsFollowUp = false
// Workspace items the CURRENT chat modified via AI tool calls, as
@@ -2880,13 +2884,13 @@ export class AIChatManager {
sendUserToast('This action needs the AI chat. Start an AI session to continue.', true)
return
}
// Still holding a mirrored transcript against the pre-turn history: the
// other tab's run has ended but this one has not finished reading what it
// left behind. Sending now would put that stale history to the model and
// persist it over the driver's completed turn.
// Still pairing a mirrored transcript with the pre-run history: sending now
// would put a conversation the driving tab has already moved past to the
// model, and persist it over what that tab saved. The composer is locked
// for exactly this window, so nothing the user typed reaches here — this
// catches the sends that start without one (tool handlers, auto-resume).
if (this.mirroringRemoteRun) {
this.restoreRefusedSend(options)
sendUserToast('Catching up on the turn that just finished. Try again in a moment.', true)
return false
}
this.#sendsInFlight++
@@ -11,6 +11,10 @@ import { chatState } from './sharedChatState.svelte'
import { PLAN_MODE_MESSAGES } from './planModeMessages'
import { runChatLoop } from './chatLoop'
import { clearWorkspaceRoleCache } from '$lib/user'
import {
noteDriverAlive,
noteRemoteTurnEnded
} from '$lib/components/sessions/sessionRunOwner.svelte'
// This suite forces esm-env BROWSER=true (below). That makes @sveltejs/kit's
// client runtime (pulled transitively via $lib/navigation) evaluate browser-only
@@ -295,8 +299,9 @@ describe('AIChatManager cross-tab run guard', () => {
it('does not persist while rendering a run another tab owns', async () => {
const manager = new AIChatManager()
manager.isSessionChat = true
manager.sessionId = 'session-watching'
const saveChat = vi.spyOn(manager.historyManager, 'saveChat').mockResolvedValue(undefined)
manager.mirroringRemoteRun = true
noteDriverAlive('session-watching', false)
await manager.saveAndClear()
await manager.compactManually()
@@ -304,6 +309,21 @@ describe('AIChatManager cross-tab run guard', () => {
expect(saveChat).not.toHaveBeenCalled()
})
// The posture describes the run it arrived with, and the driving tab stops
// saying anything about it once that run is over. Outliving it is how a turn
// driven from this tab ends up promising a plan mode it is not in.
it('drops the mirrored plan posture when the remote run ends', () => {
const manager = new AIChatManager()
manager.isSessionChat = true
manager.sessionId = 'session-planning'
noteDriverAlive('session-planning', true)
expect(manager.mirroredPlanMode).toBe(true)
noteRemoteTurnEnded('session-planning')
expect(manager.mirroredPlanMode).toBe(false)
})
// The other tab's run has ended but this one is still reading what it left
// behind, so its transcript is mirrored while `messages` is the pre-turn
// history. A send in that window would put the stale history to the model and
@@ -311,7 +331,9 @@ describe('AIChatManager cross-tab run guard', () => {
it('refuses to send while still catching up on a finished remote turn', async () => {
const manager = new AIChatManager()
manager.isSessionChat = true
manager.mirroringRemoteRun = true
manager.sessionId = 'session-catching-up'
noteDriverAlive('session-catching-up', false)
noteRemoteTurnEnded('session-catching-up')
const restoreInstructions = vi.fn(() => true)
manager.setAiChatInput({ restoreInstructions } as any)
@@ -0,0 +1,228 @@
import { BROWSER } from 'esm-env'
import { SvelteMap } from 'svelte/reactivity'
// Who holds a session's run, for every tab open on it.
//
// Two tabs sending on one session append to the same chat id, and whichever
// saveChat lands last silently drops the other's turn — after its tool calls
// have already run against the workspace. So one tab drives and the rest watch.
//
// Every gate in the cross-tab paths asks a variant of one question: may I send,
// is this frame mine to adopt, is what I am showing still the other tab's. This
// module answers all of them from a single position per session, so they cannot
// disagree with each other.
/** Whether this context can hold a real lock on a run. Web Locks is
* secure-context only, so a self-hosted instance served over plain HTTP has
* none — watching still works there, on the weaker footing described at
* {@link withSessionRunLock} and {@link runLockHeld}. */
const EXCLUSIVE_OWNERSHIP = BROWSER && !!globalThis.navigator?.locks?.query
/** A driver with no frame for this long is presumed gone, pending the lock
* query that confirms it. Deliberately many times the frame cadence: a busy
* tab can be starved of ticks for a while without having gone anywhere. */
const DRIVER_SILENCE_MS = 10_000
/** Where this tab stands in one session's run.
*
* `catchingUp` is its own position rather than a corner of `watching`: the
* driver's turn is over, but this tab still pairs a mirrored transcript with
* the history it held before that turn, and sending from that pair would put a
* conversation the driver has already moved past to the model. */
export type RunPosition =
| { state: 'idle' }
| { state: 'driving' }
| {
state: 'watching'
lastHeardAt: number
/** The driver's plan-mode posture, carried so a watching tab can show the
* mode the run is actually under. It lives here rather than on the
* manager so it cannot outlive the run it describes: the position moves
* on at turn end and the posture goes with it. */
planMode: boolean
}
| { state: 'catchingUp' }
const IDLE: RunPosition = { state: 'idle' }
/** Reactive because the composer's locked state is derived from it. */
const positions = new SvelteMap<string, RunPosition>()
/** Sessions with no entry are idle, which is also the answer for a chat that
* has no session at all (the docked side-panel copilot). */
export function runPosition(sessionId: string | undefined): RunPosition {
if (!sessionId) return IDLE
return positions.get(sessionId) ?? IDLE
}
/** This tab is running the turn. */
export function isDriving(sessionId: string): boolean {
return runPosition(sessionId).state === 'driving'
}
/** Another tab is running the turn and this one is rendering its frames. */
export function isWatching(sessionId: string): boolean {
return runPosition(sessionId).state === 'watching'
}
/** True from the first frame adopted until the re-read that follows the
* driver's turn completes. The save and send paths gate on it: until it
* clears, this tab's transcript and its model history are not the same
* conversation. */
export function isMirroring(sessionId: string | undefined): boolean {
const state = runPosition(sessionId).state
return state === 'watching' || state === 'catchingUp'
}
/** A frame arrived, which is both the transcript and the sign of life. */
export function noteDriverAlive(sessionId: string, planMode: boolean): void {
// A tab mid-turn is the authority on its own session; a frame reaching it can
// only be an echo of the run it is itself driving.
if (isDriving(sessionId)) return
positions.set(sessionId, { state: 'watching', lastHeardAt: Date.now(), planMode })
ensureReaper()
}
/** The driver says its turn is over. The re-read that follows is what actually
* frees this tab, so the position moves to `catchingUp` rather than to idle. */
export function noteRemoteTurnEnded(sessionId: string): void {
if (runPosition(sessionId).state !== 'watching') return
positions.set(sessionId, { state: 'catchingUp' })
}
/** The re-read finished: this tab's transcript and history are one conversation
* again, and it may drive the next turn. */
export function noteCaughtUp(sessionId: string): void {
if (runPosition(sessionId).state !== 'catchingUp') return
positions.delete(sessionId)
}
/** Drop everything held for a session whose runtime is going away, so a torn
* down tab can't leave a position behind for a session nothing is watching. */
export function clearRunPosition(sessionId: string): void {
positions.delete(sessionId)
}
function lockName(sessionId: string): string {
return `wm-session-run:${sessionId}`
}
/** Run `body` as the session's sole driver, or return 'busy' without running it
* when another tab already holds the run. `body` runs at most once either way.
*
* Web Locks is what makes this safe across a crash: the lock is held by the tab,
* not by a record someone has to clean up, so a driver that dies mid-turn
* releases it and the next send succeeds. Where it is missing or unusable,
* {@link bestEffort} takes over. */
export async function withSessionRunLock<T>(
sessionId: string,
body: () => Promise<T>
): Promise<T | 'busy'> {
if (!EXCLUSIVE_OWNERSHIP) return await bestEffort(sessionId, body)
// Set before the body runs, so a turn that throws is told apart from a lock
// that could not be taken. Without it a failing turn would look like a failed
// arbitration and be run a second time by the fallback below.
let entered = false
try {
return (await navigator.locks.request(
lockName(sessionId),
{ mode: 'exclusive', ifAvailable: true },
async (lock) => {
// `ifAvailable` hands back a null lock instead of queueing when another
// tab holds it, which is exactly the "refuse, don't stack up turns"
// behavior we want.
if (!lock) return 'busy' as const
entered = true
return await drive(sessionId, body)
}
)) as T | 'busy'
} catch (e) {
if (entered) throw e
// The lock API is present but refused to arbitrate. Degrade to the footing
// a context without it already runs on rather than failing the turn: a
// session the user cannot send in is a worse outcome than one whose
// exclusion is best-effort for this send.
console.error('sessionRunOwner: run lock unavailable, excluding best-effort instead', e)
return await bestEffort(sessionId, body)
}
}
/** Exclusion without a lock: refuse only when another tab is visibly mid-run.
* That covers the case that actually happens (one tab already going) and leaves
* a genuine simultaneous start racing, which is what a session already did
* before any of this. */
async function bestEffort<T>(sessionId: string, body: () => Promise<T>): Promise<T | 'busy'> {
if (isWatching(sessionId)) return 'busy'
return await drive(sessionId, body)
}
async function drive<T>(sessionId: string, body: () => Promise<T>): Promise<T> {
positions.set(sessionId, { state: 'driving' })
try {
return await body()
} finally {
// Straight to idle: this tab wrote the turn it just ran, so there is
// nothing of anyone else's to catch up on.
positions.delete(sessionId)
}
}
/** Whether any tab currently holds the run lock for this session. Used to
* settle a driver that stopped sending frames: a released lock proves the tab
* is gone, where silence alone only suggests it. */
async function runLockHeld(sessionId: string): Promise<boolean> {
// Nothing to consult without the lock API, so silence is the only evidence —
// and the caller only asks after a driver has gone quiet for the whole
// silence window. Reaping a driver that was merely starved costs a re-read
// the next frame would have triggered anyway, and cannot cost a turn: this
// tab still has to take the lock before it can send.
if (!EXCLUSIVE_OWNERSHIP) return false
try {
const state = await navigator.locks.query()
const name = lockName(sessionId)
return !!state.held?.some((l) => l.name === name)
} catch {
return true
}
}
let driverLost: ((sessionId: string) => void) | undefined
/** Registered by sessionRuntime at module load, so this module stays free of
* its imports — the two would otherwise sit in a cycle. */
export function onDriverLost(fn: (sessionId: string) => void): void {
driverLost = fn
}
// Runs only while some session is being driven elsewhere, and stops itself once
// none is — a browser with a single tab open never arms it at all.
let reaperTimer: ReturnType<typeof setInterval> | undefined
function ensureReaper(): void {
if (reaperTimer) return
reaperTimer = setInterval(() => {
if (![...positions.values()].some((p) => p.state === 'watching')) {
clearInterval(reaperTimer)
reaperTimer = undefined
return
}
void reapDeadDrivers()
}, DRIVER_SILENCE_MS)
}
/** Release watchers whose driver went silent and no longer holds the lock, so a
* closed tab can't leave a session showing "generating" forever. */
async function reapDeadDrivers(): Promise<void> {
const now = Date.now()
const stale = [...positions.entries()]
.filter(([, p]) => p.state === 'watching' && now - p.lastHeardAt > DRIVER_SILENCE_MS)
.map(([id]) => id)
for (const id of stale) {
// Re-checked after the await: a frame may have landed while the query was
// in flight, and reaping then would tear down a run that is visibly alive.
if (await runLockHeld(id)) continue
if (!isWatching(id)) continue
noteRemoteTurnEnded(id)
driverLost?.(id)
}
}
@@ -1,11 +1,7 @@
import { SvelteMap } from 'svelte/reactivity'
import { get } from 'svelte/store'
import { base } from '$lib/base'
import {
AIAutonomyMode,
AIChatManager,
AIMode
} from '$lib/components/copilot/chat/AIChatManager.svelte'
import { AIChatManager, AIMode } from '$lib/components/copilot/chat/AIChatManager.svelte'
import { PipelineEditorState } from '$lib/components/assets/AssetGraph/pipelineEditorState.svelte'
import { initFlow } from '$lib/components/flows/flowStore.svelte'
import {
@@ -101,18 +97,23 @@ import { canSpliceFrame, mirrorFrameStart, withoutHeavyPayloads } from './sessio
import {
broadcastMirror,
broadcastTurnEnd,
isLocallyDriven,
isRemotelyDriven,
MIRROR_THROTTLE_MS,
registerSyncHandlers,
requestCancel,
requestResync,
sendQuestionAnswer,
sendToolConfirmation,
withSessionRunLock,
type MirrorMsg,
type MirrorSnapshot
} from './sessionSync.svelte'
import {
clearRunPosition,
isDriving,
isWatching,
noteCaughtUp,
onDriverLost,
withSessionRunLock
} from './sessionRunOwner.svelte'
// Per-kind load state for a session's editor target. Pure state container the
// load methods write into; the editor-target gate reads it to decide between
@@ -940,16 +941,6 @@ async function initRuntime(runtime: SessionRuntime, session: Session) {
// turns interleave into one chat id.
manager.runGuard = async (body) => {
const outcome = await withSessionRunLock(session.id, async () => {
// The picker has read Plan since the last driver left the session in it,
// and this send is the user acting on what it says. Take the posture on
// rather than drop it: running under this tab's own autonomy instead
// would unblock the very workspace tools the label promised were held
// back. Once it is genuinely this manager's mode, the mirrored copy has
// nothing left to say.
if (manager.mirroredPlanMode) {
if (manager.planModeAvailable) manager.setAutonomyMode(AIAutonomyMode.PLAN)
manager.mirroredPlanMode = false
}
// The first frame doubles as the "a run started here" signal: it is
// posted immediately and carries the chat id the watchers need.
startMirroring(session.id)
@@ -959,14 +950,7 @@ async function initRuntime(runtime: SessionRuntime, session: Session) {
stopMirroring(session.id)
// The chat id is re-read here, not reused from above: the turn may
// have rotated it, and the listeners key their IndexedDB re-read on it.
broadcastTurnEnd(
session.id,
manager.historyManager.getCurrentChatId(),
// Not "did the send return truthy": that means the input was
// consumed, and stays true through provider errors and rollbacks.
// The manager reports the rule it applies to its own queue.
manager.lastTurnAcceptsFollowUp
)
broadcastTurnEnd(session.id, manager.historyManager.getCurrentChatId())
}
})
if (outcome === 'busy') {
@@ -978,7 +962,7 @@ async function initRuntime(runtime: SessionRuntime, session: Session) {
// Stop is available wherever the run is visible, so from a watching tab it
// has to travel to the one holding the turn.
manager.remoteCancel = () => {
if (isLocallyDriven(session.id) || !isRemotelyDriven(session.id)) return false
if (!isWatching(session.id)) return false
requestCancel(session.id)
return true
}
@@ -986,12 +970,12 @@ async function initRuntime(runtime: SessionRuntime, session: Session) {
// Same for a run parked on the user: the tab showing the prompt is not
// necessarily the tab whose loop is awaiting the answer.
manager.remoteToolConfirmation = (toolId, confirmed) => {
if (isLocallyDriven(session.id) || !isRemotelyDriven(session.id)) return false
if (!isWatching(session.id)) return false
sendToolConfirmation(session.id, toolId, confirmed)
return true
}
manager.remoteQuestionAnswer = (toolId, choices) => {
if (isLocallyDriven(session.id) || !isRemotelyDriven(session.id)) return false
if (!isWatching(session.id)) return false
sendQuestionAnswer(session.id, toolId, choices)
return true
}
@@ -1114,7 +1098,7 @@ function stopMirroring(sessionId: string): void {
function applyMirror(msg: MirrorMsg): void {
// A tab mid-turn is the authority on its own session; a frame can only be
// an echo of a run this tab is itself driving.
if (isLocallyDriven(msg.sessionId)) return
if (isDriving(msg.sessionId)) return
const runtime = runtimes.get(msg.sessionId)
if (!runtime) return
const m = runtime.manager
@@ -1145,23 +1129,17 @@ function applyMirror(msg: MirrorMsg): void {
// holds complete from the store.
m.displayMessages =
msg.baseIndex === 0 ? msg.tail : [...m.displayMessages.slice(0, msg.baseIndex), ...msg.tail]
// The frame carries the rendered transcript but not the API-format history,
// so this manager is now holding a mismatched pair. Flag it: the save paths
// that run outside a turn would otherwise write that pair over the record the
// driving tab is still appending to.
m.mirroringRemoteRun = true
m.loading = msg.loading
m.currentReply = msg.currentReply
m.currentReasoning = msg.currentReasoning
m.currentReasoningActive = msg.currentReasoningActive
m.loadingLabel = msg.loadingLabel
m.compacting = msg.compacting
m.mirroredPlanMode = msg.planModeActive
}
/** The driver answers a resync with its whole transcript. */
function answerResync(sessionId: string): void {
if (!isLocallyDriven(sessionId)) return
if (!isDriving(sessionId)) return
postMirror(sessionId, { full: true })
}
@@ -1169,27 +1147,28 @@ function answerResync(sessionId: string): void {
* only, so re-read the record the driver just saved for everything else — the
* API-format history, context usage, the edits mask, background jobs — leaving
* this tab able to take the conversation over. */
async function applyTurnEnd(sessionId: string, chatId: string, committed: boolean): Promise<void> {
if (isLocallyDriven(sessionId)) return
const runtime = runtimes.get(sessionId)
if (!runtime) return
const m = runtime.manager
// `loading` has to go first: loadPastChat refuses to run while the manager
// looks busy, and this one is the mirrored driver's, not a turn of our own.
m.loading = false
m.currentReply = ''
m.currentReasoning = ''
m.currentReasoningActive = false
m.loadingLabel = undefined
m.compacting = false
const id = chatId || m.historyManager.getCurrentChatId()
async function applyTurnEnd(sessionId: string, chatId: string): Promise<void> {
if (isDriving(sessionId)) return
try {
const runtime = runtimes.get(sessionId)
if (!runtime) return
const m = runtime.manager
// `loading` has to go first: loadPastChat refuses to run while the manager
// looks busy, and this one is the mirrored driver's, not a turn of our own.
m.loading = false
m.currentReply = ''
m.currentReasoning = ''
m.currentReasoningActive = false
m.loadingLabel = undefined
m.compacting = false
const id = chatId || m.historyManager.getCurrentChatId()
if (!id) return
const found = await m.historyManager.reloadChat(id)
if (found === 'loaded') {
// `refresh`: the same conversation caught up from the store, so a message
// queued here while the other tab held the session is still meant for it.
// A plain load would drop it instead of sending it below.
// `refresh`: the same conversation caught up from the store, so whatever
// this manager still holds for it — a queued message, the background-job
// tray — belongs to the chat being reloaded rather than to a chat being
// left, and a plain load would drop it.
await m.loadPastChat(id, { refresh: true })
} else if (found === 'missing') {
// The driver rotated to a chat that holds nothing: it ran "/clear", or its
@@ -1202,30 +1181,16 @@ async function applyTurnEnd(sessionId: string, chatId: string, committed: boolea
// 'unavailable' leaves everything as it is: the store is unreadable right
// now, which says nothing about the conversation.
} finally {
// Cleared only once the catch-up is done. Until then this manager still
// pairs a mirrored transcript with the pre-turn history, and the send and
// save paths gate on this flag to stay off that pair.
m.mirroringRemoteRun = false
// Unconditional, including the paths that never reached the store: leaving
// the position at `catchingUp` would lock this tab's composer for a run
// that is already over, with nothing left to arrive and free it.
noteCaughtUp(sessionId)
}
// Anything typed here while the other tab held the session was queued rather
// than sent. Send it only after a turn that landed, which is the rule a turn
// follows locally: firing it into a failed turn, or into the gap left by a tab
// that vanished, is how a follow-up ends up answering nothing.
if (!committed) return
if (m.mirroredPlanMode) {
// Plan mode belongs to the tab that entered it, and a turn sent from here
// would run under this tab's own autonomy instead — unblocking the very
// workspace tools the posture exists to hold back. Leave the message where
// the user put it and say why, rather than quietly sending it out of mode.
sendUserToast('This session is planning in another tab. Your message stays queued.')
return
}
await m.flushQueuedMessage()
}
/** A Stop pressed in a watching tab reaches the run here. */
function applyCancelRequest(sessionId: string): void {
if (!isLocallyDriven(sessionId)) return
if (!isDriving(sessionId)) return
// No reason: this IS the user's Stop, just pressed elsewhere, and the
// queued-message and rollback paths key off that.
runtimes.get(sessionId)?.manager.cancel()
@@ -1235,12 +1200,12 @@ function applyCancelRequest(sessionId: string): void {
// most once — the driver drops the callback as it resolves it — so two tabs
// answering the same prompt is a race the first click simply wins.
function applyToolConfirmation(sessionId: string, toolId: string, confirmed: boolean): void {
if (!isLocallyDriven(sessionId)) return
if (!isDriving(sessionId)) return
runtimes.get(sessionId)?.manager.handleToolConfirmation(toolId, confirmed)
}
function applyQuestionAnswer(sessionId: string, toolId: string, choices: string[]): void {
if (!isLocallyDriven(sessionId)) return
if (!isDriving(sessionId)) return
runtimes.get(sessionId)?.manager.handleUserQuestionAnswer(toolId, choices)
}
@@ -1250,7 +1215,7 @@ registerSyncHandlers({
onCancelRequest: applyCancelRequest,
onToolConfirmation: applyToolConfirmation,
onQuestionAnswer: applyQuestionAnswer,
onTurnEnd: (sessionId, chatId, committed) => void applyTurnEnd(sessionId, chatId, committed),
onTurnEnd: (sessionId, chatId) => void applyTurnEnd(sessionId, chatId),
// A session deleted in another tab takes its runtime with it, so an open
// chat for it stops streaming and releases its editors.
onSessionDelete: (id) => disposeRuntime(id),
@@ -1261,6 +1226,11 @@ registerSyncHandlers({
void runtimes.get(sessionId)?.manager.artifacts.applyRemoteArtifact(artifactId)
})
// A driving tab that closed mid-turn sends no turn-end. Its watchers land here
// instead, and take the same path: stop showing the run, re-read whatever the
// store holds, and become able to send again.
onDriverLost((sessionId) => void applyTurnEnd(sessionId, ''))
export function getOrCreateRuntime(session: Session): SessionRuntime {
let runtime = runtimes.get(session.id)
if (!runtime) {
@@ -1277,6 +1247,11 @@ export function disposeRuntime(sessionId: string) {
runtime.manager.cancel('runtime disposed')
runtime.manager.historyManager.close()
runtimes.delete(sessionId)
// The per-run bookkeeping outlives the manager it describes otherwise: a
// frame timer with no runtime to read keeps ticking, and a leftover position
// would answer for a session this tab no longer holds.
stopMirroring(sessionId)
clearRunPosition(sessionId)
}
export function listRuntimes(): SessionRuntime[] {
@@ -1,38 +1,24 @@
import { BROWSER } from 'esm-env'
import { SvelteMap, SvelteSet } from 'svelte/reactivity'
import { onUserChange, scopedKey } from '$lib/userScopedStorage'
import type { DisplayMessage } from '$lib/components/copilot/chat/shared'
import { noteDriverAlive, noteRemoteTurnEnded } from './sessionRunOwner.svelte'
// Cross-tab coordination for AI sessions. Everything a session is made of —
// the record list, the chat transcript, the run itself — lives in the tab, so
// two tabs on the same session are two independent copies of it. This module
// is the one channel between them: it mirrors record writes, elects a single
// driving tab per run, and streams the driver's live transcript to the others.
//
// Why a run needs an owner at all: two tabs sending on one session append to
// the same chat id, and whichever saveChat lands last silently drops the other's
// turn — after its tool calls have already run against the workspace.
// The channel between tabs open on the same AI session. Everything a session is
// made of — the record list, the chat transcript, the run itself — lives in the
// tab, so two tabs on one session are two independent copies of it. This module
// carries messages between those copies: record writes, and the driving tab's
// live transcript. Who is entitled to drive is sessionRunOwner's question; this
// module only tells it what arrived.
//
// The channel is per-user (same email scoping as the IndexedDB stores), so a
// browser shared by two accounts never crosses them.
const CHANNEL_BASE = 'windmill-sessions-sync'
/** Whether this context can hold a real lock on a run. Web Locks is
* secure-context only, so a self-hosted instance served over plain HTTP has
* none mirroring still runs there, on the weaker footing described at
* {@link withSessionRunLock} and {@link runLockHeld}. */
const EXCLUSIVE_OWNERSHIP = BROWSER && !!globalThis.navigator?.locks?.query
/** Mirror ticks are throttled to this while a turn streams. Also the heartbeat
* interval: an unchanged run still ticks, so silence means the driver is gone. */
export const MIRROR_THROTTLE_MS = 250
/** A driver with no mirror for this long is presumed dead, pending the lock
* query that confirms it. Generous next to the heartbeat: a busy tab can be
* starved of frames for a while without actually having gone away. */
const MIRROR_SILENCE_MS = 10_000
/** Carries only the id on purpose. Broadcast delivery order and IndexedDB
* commit order are independent, so shipping a copy of the record lets an older
* write land on top of a newer one; re-reading converges on what the shared
@@ -44,10 +30,12 @@ type SessionDeleteMsg = { kind: 'session-delete'; id: string }
* removal arrives. `sessionId` is here so a receiver can route to the right
* store without a database round-trip for artifacts it does not hold. */
type SessionArtifactMsg = { kind: 'session-artifact'; sessionId: string; artifactId: string }
/** `committed` distinguishes a turn that landed from one that errored, was
* rolled back, or belonged to a tab that vanished. Watchers auto-send what the
* user queued only on the first, matching the rule a turn follows locally. */
type TurnEndMsg = { kind: 'turn-end'; sessionId: string; chatId: string; committed: boolean }
/** Names the chat the driver ended on, which is not necessarily the one it
* started on: a "/clear" rotates it mid-turn, and the watcher's re-read has to
* follow. Whether the turn landed or errored is not carried, because the
* watcher does the same thing either way re-read the record and stop
* showing the run. */
type TurnEndMsg = { kind: 'turn-end'; sessionId: string; chatId: string }
type MirrorMsg = {
kind: 'mirror'
sessionId: string
@@ -108,7 +96,7 @@ type Handlers = {
onSessionPut: (id: string) => void
onSessionDelete: (id: string) => void
onSessionArtifact: (sessionId: string, artifactId: string) => void
onTurnEnd: (sessionId: string, chatId: string, committed: boolean) => void
onTurnEnd: (sessionId: string, chatId: string) => void
onMirror: (msg: MirrorMsg) => void
onResyncRequest: (sessionId: string) => void
onCancelRequest: (sessionId: string) => void
@@ -181,11 +169,11 @@ function receive(msg: SyncMsg): void {
emit('onSessionArtifact', msg.sessionId, msg.artifactId)
break
case 'turn-end':
remoteDriven.delete(msg.sessionId)
emit('onTurnEnd', msg.sessionId, msg.chatId, msg.committed)
noteRemoteTurnEnded(msg.sessionId)
emit('onTurnEnd', msg.sessionId, msg.chatId)
break
case 'mirror':
noteDriverAlive(msg.sessionId)
noteDriverAlive(msg.sessionId, msg.planModeActive)
emit('onMirror', msg)
break
case 'resync-request':
@@ -229,135 +217,12 @@ export function broadcastSessionArtifact(sessionId: string, artifactId: string):
post({ kind: 'session-artifact', sessionId, artifactId })
}
// ---------------------------------------------------------------------------
// Ownership
// ---------------------------------------------------------------------------
/** Sessions currently being driven by another tab, with the time of the last
* sign of life. Reactive so a tab that starts or stops driving re-renders the
* gates that read it. */
const remoteDriven = new SvelteMap<string, { lastAt: number }>()
function noteDriverAlive(sessionId: string): void {
remoteDriven.set(sessionId, { lastAt: Date.now() })
ensureReaper()
}
// Runs only while some session is being driven elsewhere, and stops itself once
// none is — a browser with a single tab open never arms it at all.
let reaperTimer: ReturnType<typeof setInterval> | undefined
function ensureReaper(): void {
if (reaperTimer) return
reaperTimer = setInterval(() => {
if (remoteDriven.size === 0) {
clearInterval(reaperTimer)
reaperTimer = undefined
return
}
void reapDeadDrivers()
}, MIRROR_SILENCE_MS)
}
export function isRemotelyDriven(sessionId: string): boolean {
return remoteDriven.has(sessionId)
}
/** Sessions this tab is currently driving. Reactive so the gates that read it
* re-render when a run starts or ends. */
const locallyDriven = new SvelteSet<string>()
export function isLocallyDriven(sessionId: string): boolean {
return locallyDriven.has(sessionId)
}
function lockName(sessionId: string): string {
return `wm-session-run:${sessionId}`
}
/** Run `body` as the session's sole driver, or return 'busy' without running it
* when another tab already holds the run.
*
* Web Locks is what makes this safe across a crash: the lock is held by the tab,
* not by a record someone has to clean up, so a driver that dies mid-turn
* releases it and the next send succeeds. */
export async function withSessionRunLock<T>(
sessionId: string,
body: () => Promise<T>
): Promise<T | 'busy'> {
if (!EXCLUSIVE_OWNERSHIP) {
// No lock to take, so exclusion is best-effort: refuse only when another
// tab is visibly mid-run. That covers the case that actually happens (one
// tab already going) and leaves a genuine simultaneous start racing, which
// is what a session already did before any of this. Marking the run ours
// is what keeps the two tabs from adopting each other's frames.
if (isRemotelyDriven(sessionId)) return 'busy'
locallyDriven.add(sessionId)
try {
return await body()
} finally {
locallyDriven.delete(sessionId)
}
}
return (await navigator.locks.request(
lockName(sessionId),
{ mode: 'exclusive', ifAvailable: true },
async (lock) => {
// `ifAvailable` hands back a null lock instead of queueing when another
// tab holds it, which is exactly the "refuse, don't stack up turns"
// behavior we want.
if (!lock) return 'busy' as const
locallyDriven.add(sessionId)
try {
return await body()
} finally {
locallyDriven.delete(sessionId)
}
}
)) as T | 'busy'
}
/** Whether any tab currently holds the run lock for this session. Used to
* settle a driver that stopped mirroring: a released lock proves the tab is
* gone, where silence alone only suggests it. */
async function runLockHeld(sessionId: string): Promise<boolean> {
// Nothing to consult without the lock API, so silence is the only evidence —
// and the caller only asks after a driver has gone quiet for the whole
// silence window. Reaping a driver that was merely starved is self-correcting:
// the watcher stops showing a run that is not visibly progressing and re-reads
// the record, which the next frame or the turn-end would have done anyway.
if (!EXCLUSIVE_OWNERSHIP) return false
try {
const state = await navigator.locks.query()
const name = lockName(sessionId)
return !!state.held?.some((l) => l.name === name)
} catch {
return true
}
}
/** Drop drivers that have gone silent and whose lock is no longer held, so a
* closed tab can't leave a session showing "generating" forever. */
async function reapDeadDrivers(): Promise<void> {
const now = Date.now()
const stale = [...remoteDriven.entries()]
.filter(([, v]) => now - v.lastAt > MIRROR_SILENCE_MS)
.map(([id]) => id)
for (const id of stale) {
if (!(await runLockHeld(id))) {
remoteDriven.delete(id)
// A driver that disappeared mid-turn committed nothing.
emit('onTurnEnd', id, '', false)
}
}
}
// ---------------------------------------------------------------------------
// Live mirroring
// ---------------------------------------------------------------------------
export function broadcastTurnEnd(sessionId: string, chatId: string, committed: boolean): void {
post({ kind: 'turn-end', sessionId, chatId, committed })
export function broadcastTurnEnd(sessionId: string, chatId: string): void {
post({ kind: 'turn-end', sessionId, chatId })
}
export function requestResync(sessionId: string): void {