mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
refactor(ai-sessions): replace transcript mirroring with a run-status heartbeat
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cd3d24776e
commit
e13c713969
@@ -501,8 +501,8 @@
|
||||
)
|
||||
// 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
|
||||
const showsRemotePlan = $derived(
|
||||
aiChatManager.remotePlanMode && aiChatManager.planModeAvailable
|
||||
)
|
||||
// Fall back to ask-permission when the persisted mode isn't applicable in the
|
||||
// current AI mode (e.g. auto-accept edits while in a mode without edits).
|
||||
@@ -510,7 +510,7 @@
|
||||
// run this tab is showing, and this tab's own mode governs nothing until it
|
||||
// drives a turn itself.
|
||||
const effectiveAutonomyMode = $derived(
|
||||
showsMirroredPlan
|
||||
showsRemotePlan
|
||||
? AIAutonomyMode.PLAN
|
||||
: availableAutonomyModeOptions.some((option) => option.mode === aiChatManager.autonomyMode)
|
||||
? aiChatManager.autonomyMode
|
||||
@@ -535,16 +535,16 @@
|
||||
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
|
||||
)
|
||||
// The composer is locked for as long as another tab's run is in flight: this
|
||||
// tab still shows the transcript from before that run, and a turn sent from
|
||||
// it 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.
|
||||
//
|
||||
// No exception for a run parked on a question. Only the driving tab holds the
|
||||
// question and the resolver waiting on it; a card that looks parked here is a
|
||||
// restored one whose resolver left with the old page, and answering it would
|
||||
// deliver to nobody.
|
||||
const composerLocked = $derived(aiChatManager.runHeldElsewhere)
|
||||
|
||||
// Get app context for display when in APP mode
|
||||
const appContext = $derived.by((): SelectedContext | undefined => {
|
||||
@@ -639,10 +639,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
|
||||
? 'This session is running in another tab'
|
||||
: aiChatManager.loading || aiChatManager.sendInFlight
|
||||
? 'Stop the current answer to switch conversation'
|
||||
: undefined}
|
||||
onclick={() => {
|
||||
loadPastChat(chat.id)
|
||||
close()
|
||||
@@ -654,11 +658,15 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
>
|
||||
{chat.title}
|
||||
</div>
|
||||
<!-- Gated separately: it sits inside the disabled row button,
|
||||
which does not disable it, and the catch-up re-read is
|
||||
keyed on a chat id this would delete out from under it. -->
|
||||
<Button
|
||||
iconOnly
|
||||
size="xs2"
|
||||
btnClasses="!p-1"
|
||||
variant="default"
|
||||
disabled={aiChatManager.runHeldElsewhere}
|
||||
startIcon={{ icon: X }}
|
||||
on:click={() => {
|
||||
deletePastChat(chat.id)
|
||||
@@ -672,10 +680,10 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{/snippet}
|
||||
</Popover>
|
||||
<Button
|
||||
title={aiChatManager.mirroringRemoteRun
|
||||
title={aiChatManager.runHeldElsewhere
|
||||
? 'This session is running in another tab'
|
||||
: 'New chat'}
|
||||
disabled={aiChatManager.mirroringRemoteRun}
|
||||
disabled={aiChatManager.runHeldElsewhere}
|
||||
on:click={() => {
|
||||
saveAndClear()
|
||||
}}
|
||||
@@ -976,7 +984,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
onchange={onFolderInputChange}
|
||||
/>
|
||||
{/if}
|
||||
{#if showsMirroredPlan}
|
||||
{#if showsRemotePlan}
|
||||
<!-- The posture belongs to the tab running the turn, so it reads here
|
||||
rather than offering a choice that would only move this tab's own
|
||||
preference while the label stayed put. -->
|
||||
|
||||
@@ -133,10 +133,10 @@
|
||||
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) {
|
||||
// Ahead of 'Ask followup': a run held elsewhere 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.runHeldElsewhere) {
|
||||
return 'Running in another tab'
|
||||
}
|
||||
|
||||
@@ -704,6 +704,15 @@
|
||||
// Read before `take()` empties the draft the id derives from, and only take
|
||||
// once the answer is delivered — an undelivered one would leave the user
|
||||
// with neither their text nor a resumed turn.
|
||||
// Ahead of every `draft.take()` below, including the edit branch: a send
|
||||
// refused after the draft is consumed loses the user's text, and
|
||||
// restartGeneration has truncated the transcript by then as well. The
|
||||
// composer is disabled while another tab drives, so this catches the ways
|
||||
// in that bypass it — Enter, and submitting an edit.
|
||||
if (aiChatManager.runHeldElsewhere) {
|
||||
sendUserToast('This session is running in another tab. Continue it there.')
|
||||
return
|
||||
}
|
||||
const answeredQuestionId = questionAnsweredBySend
|
||||
if (
|
||||
answeredQuestionId &&
|
||||
@@ -716,16 +725,6 @@
|
||||
return
|
||||
}
|
||||
if (aiChatManager.loading) {
|
||||
// The composer is locked while another tab drives, with one exception:
|
||||
// a run parked on a question stays answerable from here. An answer
|
||||
// carrying an attachment misses the branch above (it only routes plain
|
||||
// choices) and would land on the queue — which in this tab nothing
|
||||
// drains, because the turn that would flush it belongs elsewhere. Keep
|
||||
// the draft where the user put it and say what will send.
|
||||
if (aiChatManager.mirroringRemoteRun) {
|
||||
sendUserToast('This session is running in another tab. Answer with text to send it there.')
|
||||
return
|
||||
}
|
||||
// Queue the message instead of silently discarding it — it is auto-sent
|
||||
// when this tab's own streaming turn completes successfully.
|
||||
// Editing-while-loading keeps the old discard behavior. Paste
|
||||
|
||||
@@ -61,7 +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 { runHeldElsewhere, runPosition } from '$lib/components/sessions/sessionRunOwner.svelte'
|
||||
import { getStringError } from './utils'
|
||||
import { type PasteAttachment } from './pasteTokens'
|
||||
import {
|
||||
@@ -695,17 +695,16 @@ export class AIChatManager {
|
||||
remoteToolConfirmation: ((toolId: string, confirmed: boolean) => boolean) | undefined = undefined
|
||||
remoteQuestionAnswer: ((toolId: string, choices: string[]) => boolean) | undefined = undefined
|
||||
|
||||
/** 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.
|
||||
/** True while another tab is driving this session's run, and on through the
|
||||
* re-read that follows it. What this manager holds is the conversation from
|
||||
* before that turn, so it 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)
|
||||
* with no session (the docked copilot) never holds a run elsewhere. */
|
||||
get runHeldElsewhere(): boolean {
|
||||
return runHeldElsewhere(this.sessionId)
|
||||
}
|
||||
|
||||
/** Whether the run on screen is one another tab is running in plan mode.
|
||||
@@ -715,7 +714,7 @@ export class AIChatManager {
|
||||
* 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 {
|
||||
get remotePlanMode(): boolean {
|
||||
const position = runPosition(this.sessionId)
|
||||
return position.state === 'watching' && position.planMode
|
||||
}
|
||||
@@ -804,7 +803,7 @@ export class AIChatManager {
|
||||
// than deferred: the re-read when the run ends reseeds the mask from the
|
||||
// driving tab's record, so a write held back here would be overwritten by
|
||||
// it anyway — and writing now would put a mismatched pair in the record.
|
||||
if (this.mirroringRemoteRun) return this.#maskPersistQueue
|
||||
if (this.runHeldElsewhere) return this.#maskPersistQueue
|
||||
this.#maskPersistQueue = this.#maskPersistQueue.then(() =>
|
||||
this.historyManager
|
||||
.saveChat(
|
||||
@@ -1116,7 +1115,7 @@ export class AIChatManager {
|
||||
#jobPersistQueue: Promise<void> = Promise.resolve()
|
||||
#persistBackgroundJobs(): Promise<void> {
|
||||
// Runs outside any turn, so the run guard never sees it.
|
||||
if (this.mirroringRemoteRun) return this.#jobPersistQueue
|
||||
if (this.runHeldElsewhere) return this.#jobPersistQueue
|
||||
this.#jobPersistQueue = this.#jobPersistQueue.then(() =>
|
||||
this.historyManager
|
||||
.saveChat(
|
||||
@@ -1540,9 +1539,9 @@ export class AIChatManager {
|
||||
* summary, leaving history untouched.
|
||||
*/
|
||||
compactManually = async (): Promise<void> => {
|
||||
// `loading` is the mirrored driver's while another tab runs the session;
|
||||
// `loading` reflects the driver's run while another tab has the session;
|
||||
// compacting here would rewrite a conversation this tab does not own.
|
||||
if (this.loading || this.mirroringRemoteRun) {
|
||||
if (this.loading || this.runHeldElsewhere) {
|
||||
return
|
||||
}
|
||||
// A summary round-trip only pays off once there's a prior exchange to fold
|
||||
@@ -1803,7 +1802,7 @@ export class AIChatManager {
|
||||
context?: ContextElement[],
|
||||
files: AttachedTextFile[] = []
|
||||
) {
|
||||
if (this.mirroringRemoteRun) {
|
||||
if (this.runHeldElsewhere) {
|
||||
// Handed back, not dropped. These senders have no draft of their own to
|
||||
// fall back on — the raw-app inline prompt would lose what the user
|
||||
// typed outright. Not `restoreToInput`, whose no-composer fallback is
|
||||
@@ -2926,12 +2925,12 @@ export class AIChatManager {
|
||||
sendUserToast('This action needs the AI chat. Start an AI session to continue.', true)
|
||||
return
|
||||
}
|
||||
// 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 tab holds the conversation from before the driver's turn: sending now
|
||||
// would put one 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.runHeldElsewhere) {
|
||||
this.restoreRefusedSend(options)
|
||||
return false
|
||||
}
|
||||
@@ -4080,7 +4079,7 @@ export class AIChatManager {
|
||||
saveAndClear = async () => {
|
||||
// The transcript on screen belongs to a run another tab owns; saving it
|
||||
// here would write a history this manager does not have.
|
||||
if (this.mirroringRemoteRun) return
|
||||
if (this.runHeldElsewhere) return
|
||||
this.cancel('saveAndClear')
|
||||
// Drop any message queued in this conversation so it can't auto-send into
|
||||
// the fresh chat or linger as a card across the switch.
|
||||
|
||||
@@ -294,10 +294,9 @@ describe('AIChatManager cross-tab run guard', () => {
|
||||
})
|
||||
|
||||
// The save paths that run outside a turn have no guard over them, and a tab
|
||||
// rendering someone else's run holds a transcript that does not match its own
|
||||
// `messages` — writing that pair would clobber the record the driving tab is
|
||||
// still appending to.
|
||||
it('does not persist while rendering a run another tab owns', async () => {
|
||||
// watching someone else's run holds the conversation from before it — writing
|
||||
// that would clobber the record the driving tab is still appending to.
|
||||
it('does not persist while another tab owns the run', async () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.isSessionChat = true
|
||||
manager.sessionId = 'session-watching'
|
||||
@@ -319,16 +318,16 @@ describe('AIChatManager cross-tab run guard', () => {
|
||||
manager.sessionId = 'session-planning'
|
||||
|
||||
noteDriverAlive('session-planning', true)
|
||||
expect(manager.mirroredPlanMode).toBe(true)
|
||||
expect(manager.remotePlanMode).toBe(true)
|
||||
|
||||
noteRemoteTurnEnded('session-planning')
|
||||
expect(manager.mirroredPlanMode).toBe(false)
|
||||
expect(manager.remotePlanMode).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
|
||||
// persist it over the completed turn.
|
||||
// behind, so it holds the conversation from before that turn. A send in that
|
||||
// window would put the stale history to the model and persist it over the
|
||||
// completed turn.
|
||||
it('refuses to send while still catching up on a finished remote turn', async () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.isSessionChat = true
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { withoutHeavyPayloads } from './sessionMirrorPayload'
|
||||
import type { DisplayMessage } from '$lib/components/copilot/chat/shared'
|
||||
|
||||
// Frames go out several times a second for the whole turn, so anything left in
|
||||
// them is re-cloned and re-broadcast on every tick. The caps allow megabytes per
|
||||
// image and per pasted file, and a structural typecheck cannot catch a field
|
||||
// name that no longer exists — the message union is cast on the way out.
|
||||
describe('withoutHeavyPayloads', () => {
|
||||
it('strips the bytes and keeps what the transcript renders', () => {
|
||||
const messages = [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'look at this',
|
||||
images: [
|
||||
{ dataUrl: 'data:image/png;base64,AAAA', mediaType: 'image/png', name: 'shot.png' }
|
||||
],
|
||||
files: [{ name: 'notes.md', content: 'a very long pasted file', id: 'f1' }],
|
||||
pastes: [{ id: 1, lines: 400, content: 'a very long collapsed paste' }]
|
||||
},
|
||||
{ role: 'tool', content: 'took a screenshot', imageUrl: 'data:image/png;base64,BBBB' }
|
||||
] as unknown as DisplayMessage[]
|
||||
|
||||
const stripped = withoutHeavyPayloads(messages)
|
||||
|
||||
expect(JSON.stringify(stripped)).not.toContain('data:image')
|
||||
expect(JSON.stringify(stripped)).not.toContain('a very long pasted file')
|
||||
expect(JSON.stringify(stripped)).not.toContain('a very long collapsed paste')
|
||||
// The chip renders from the line count, so that has to survive.
|
||||
expect((stripped[0] as any).pastes).toEqual([{ id: 1, lines: 400, content: '' }])
|
||||
// The file chip is labelled from the name, so that has to survive.
|
||||
expect((stripped[0] as any).files).toEqual([{ name: 'notes.md', content: '', id: 'f1' }])
|
||||
// Images are dropped whole: the bubble renders one <img> per entry with no
|
||||
// per-image guard, so an emptied url would show a broken image instead.
|
||||
expect((stripped[0] as any).images).toBeUndefined()
|
||||
expect((stripped[0] as any).content).toBe('look at this')
|
||||
})
|
||||
|
||||
// A tool's output is the one payload with no ceiling at all — a query result
|
||||
// or job logs ride the running turn's tail, which is re-cloned and re-sent
|
||||
// several times a second until the turn ends.
|
||||
it('drops a tool result and its logs, keeping the card that frames them', () => {
|
||||
const messages = [
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'tc-1',
|
||||
content: 'Ran the query',
|
||||
toolName: 'run_script',
|
||||
parameters: { path: 'f/demo/q' },
|
||||
result: { rows: Array.from({ length: 5000 }, (_, i) => ({ i, blob: 'x'.repeat(200) })) },
|
||||
logs: 'y'.repeat(100_000)
|
||||
}
|
||||
] as unknown as DisplayMessage[]
|
||||
|
||||
const stripped = withoutHeavyPayloads(messages)
|
||||
|
||||
expect(JSON.stringify(stripped).length).toBeLessThan(500)
|
||||
expect((stripped[0] as any).result).toBeUndefined()
|
||||
expect((stripped[0] as any).logs).toBeUndefined()
|
||||
// The card is still rendered from these while the output is in flight.
|
||||
expect((stripped[0] as any).toolName).toBe('run_script')
|
||||
expect((stripped[0] as any).parameters).toEqual({ path: 'f/demo/q' })
|
||||
expect((stripped[0] as any).content).toBe('Ran the query')
|
||||
})
|
||||
|
||||
it('passes through a message with nothing heavy in it', () => {
|
||||
const messages = [{ role: 'assistant', content: 'plain reply' }] as unknown as DisplayMessage[]
|
||||
expect(withoutHeavyPayloads(messages)[0]).toBe(messages[0])
|
||||
})
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canSpliceFrame, mirrorFrameStart } from './sessionMirrorPayload'
|
||||
|
||||
describe('mirrorFrameStart', () => {
|
||||
// A resync exists to rescue a receiver whose prefix does not fit. Answering it
|
||||
// with a frame that still starts mid-transcript fails the same check again and
|
||||
// asks for another one, so the two tabs trade messages and nothing renders.
|
||||
it('sends the whole transcript when full, even mid-turn', () => {
|
||||
expect(mirrorFrameStart({ total: 40, turnStart: 36, full: true })).toBe(0)
|
||||
})
|
||||
|
||||
it('reaches no further back than the running turn', () => {
|
||||
expect(mirrorFrameStart({ total: 40, turnStart: 36, full: false })).toBe(36)
|
||||
})
|
||||
|
||||
it('caps the tail when the turn itself is long', () => {
|
||||
expect(mirrorFrameStart({ total: 40, turnStart: 2, full: false })).toBe(30)
|
||||
})
|
||||
|
||||
it('handles a turn that has produced nothing yet', () => {
|
||||
expect(mirrorFrameStart({ total: 0, turnStart: 0, full: false })).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canSpliceFrame', () => {
|
||||
const base = { baseIndex: 30, total: 40, localLength: 35, onSameChat: true }
|
||||
|
||||
it('accepts a tail that lands on a matching prefix', () => {
|
||||
expect(canSpliceFrame(base)).toBe(true)
|
||||
})
|
||||
|
||||
it('always accepts a full frame', () => {
|
||||
expect(canSpliceFrame({ ...base, baseIndex: 0, onSameChat: false })).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a tail when the receiver joined mid-run and has a gap', () => {
|
||||
expect(canSpliceFrame({ ...base, localLength: 12 })).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a tail when the receiver holds more than the sender has', () => {
|
||||
expect(canSpliceFrame({ ...base, localLength: 44 })).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a tail from a different conversation', () => {
|
||||
expect(canSpliceFrame({ ...base, onSameChat: false })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,119 +0,0 @@
|
||||
import type { DisplayMessage } from '$lib/components/copilot/chat/shared'
|
||||
|
||||
/**
|
||||
* The attachment payloads a mirror frame does not carry, and how to put them
|
||||
* back. A frame goes out several times a second for the whole turn, and the
|
||||
* message holding an image or a pasted file sits in its tail unchanged
|
||||
* throughout — so shipping the payloads means re-cloning and re-broadcasting
|
||||
* the same megabytes on every tick (the caps allow roughly 16MB of base64 image
|
||||
* plus 5MB of file content on a single message, before pastes).
|
||||
*
|
||||
* Every heavy field is listed here rather than at its own call site: they are
|
||||
* spread across the message union, and one that goes unlisted silently
|
||||
* reintroduces the whole regression.
|
||||
*/
|
||||
|
||||
/** Emptied in place, keeping the sibling fields the chip renders from. */
|
||||
const BLANKED_ITEM_FIELDS = [
|
||||
['files', 'content'],
|
||||
['pastes', 'content']
|
||||
] as const
|
||||
|
||||
/** Dropped whole: the bubble renders one <img> per entry with no per-image
|
||||
* guard, so an emptied url would show a broken image where the real one is
|
||||
* about to appear. */
|
||||
const DROPPED_LIST_FIELDS = ['images'] as const
|
||||
|
||||
/** Dropped whole, and unbounded in a way the others are not: a tool's result or
|
||||
* logs can be a whole query result or job output, and a frame re-clones and
|
||||
* re-broadcasts the running turn's tail several times a second. A watching tab
|
||||
* shows the tool card without its output until the turn-end re-read supplies
|
||||
* it, which is the same trade the images above make. */
|
||||
const DROPPED_FIELDS = ['result', 'logs'] as const
|
||||
|
||||
/** Emptied outright; guarded at the render site, so it renders nothing. */
|
||||
const BLANKED_FIELDS = ['imageUrl'] as const
|
||||
|
||||
function isHeavy(message: DisplayMessage): boolean {
|
||||
const m = message as Record<string, any>
|
||||
return (
|
||||
DROPPED_LIST_FIELDS.some((f) => m[f]?.length) ||
|
||||
DROPPED_FIELDS.some((f) => m[f] !== undefined) ||
|
||||
BLANKED_ITEM_FIELDS.some(([f]) => m[f]?.length) ||
|
||||
BLANKED_FIELDS.some((f) => m[f])
|
||||
)
|
||||
}
|
||||
|
||||
/** Strip the bytes out of a transcript bound for a mirror frame. */
|
||||
export function withoutHeavyPayloads(messages: DisplayMessage[]): DisplayMessage[] {
|
||||
return messages.map((message) => {
|
||||
if (!isHeavy(message)) return message
|
||||
const stripped: Record<string, any> = { ...message }
|
||||
for (const f of DROPPED_LIST_FIELDS) delete stripped[f]
|
||||
for (const f of DROPPED_FIELDS) delete stripped[f]
|
||||
for (const [f, item] of BLANKED_ITEM_FIELDS) {
|
||||
if (stripped[f]?.length) stripped[f] = stripped[f].map((v: any) => ({ ...v, [item]: '' }))
|
||||
}
|
||||
for (const f of BLANKED_FIELDS) if (stripped[f]) stripped[f] = ''
|
||||
return stripped as DisplayMessage
|
||||
})
|
||||
}
|
||||
|
||||
/** How many of the newest messages a frame carries when it is not sending the
|
||||
* whole transcript. In-place edits to already-rendered cards (a tool card
|
||||
* settling) land within a few messages of the end, so a short tail carries them
|
||||
* while keeping the frame bounded on a long conversation. */
|
||||
const MIRROR_TAIL = 10
|
||||
|
||||
/**
|
||||
* Where the tail a frame carries starts.
|
||||
*
|
||||
* `full` sends the whole transcript and overrides everything else — it is what
|
||||
* answers a resync, and a resync that came back partial would fail the receiver's
|
||||
* prefix check again and ask for another one, forever.
|
||||
*
|
||||
* Otherwise the frame reaches no further back than `turnStart`, the index of the
|
||||
* running turn's first message: everything from there on is either new this turn
|
||||
* or a stripped copy the receiver already got from an earlier frame of the same
|
||||
* turn, so overwriting it can never destroy a message held complete from the
|
||||
* store.
|
||||
*/
|
||||
export function mirrorFrameStart({
|
||||
total,
|
||||
turnStart,
|
||||
full
|
||||
}: {
|
||||
total: number
|
||||
turnStart: number
|
||||
full: boolean
|
||||
}): number {
|
||||
if (full) return 0
|
||||
return Math.max(turnStart, Math.max(0, total - MIRROR_TAIL))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a receiver can splice this frame onto what it already holds, or has to
|
||||
* ask for the whole transcript instead.
|
||||
*
|
||||
* A frame is positional, so it is only meaningful against the same conversation
|
||||
* and a prefix of the same shape. A receiver holding fewer messages than the
|
||||
* frame starts at has a gap; one holding more than the sender has messages the
|
||||
* sender no longer does (it compacted, or switched chats), and splicing would
|
||||
* render a conversation that never existed.
|
||||
*/
|
||||
export function canSpliceFrame({
|
||||
baseIndex,
|
||||
total,
|
||||
localLength,
|
||||
onSameChat
|
||||
}: {
|
||||
baseIndex: number
|
||||
total: number
|
||||
localLength: number
|
||||
onSameChat: boolean
|
||||
}): boolean {
|
||||
// A frame that starts at 0 replaces everything, so it needs no prefix to
|
||||
// agree with and can be adopted even when it names a different conversation.
|
||||
if (baseIndex === 0) return true
|
||||
return onSameChat && localLength >= baseIndex && localLength <= total
|
||||
}
|
||||
@@ -8,9 +8,9 @@ import { SvelteMap } from 'svelte/reactivity'
|
||||
// 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.
|
||||
// may I save, is the run on screen still someone else'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
|
||||
@@ -18,17 +18,17 @@ import { SvelteMap } from 'svelte/reactivity'
|
||||
* {@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. */
|
||||
/** A driver silent for this long is presumed gone, pending the lock query that
|
||||
* confirms it. Deliberately many times the status 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. */
|
||||
* driver's turn is over, but this tab still holds the conversation from before
|
||||
* it, and sending from there would put one the driver has already moved past to
|
||||
* the model. */
|
||||
export type RunPosition =
|
||||
| { state: 'idle' }
|
||||
| { state: 'driving' }
|
||||
@@ -60,24 +60,23 @@ export function isDriving(sessionId: string): boolean {
|
||||
return runPosition(sessionId).state === 'driving'
|
||||
}
|
||||
|
||||
/** Another tab is running the turn and this one is rendering its frames. */
|
||||
/** Another tab is running the turn and this one is showing that it is. */
|
||||
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 {
|
||||
/** True from the driver's first status message until the re-read that follows
|
||||
* its turn completes. The save and send paths gate on it: until it clears, what
|
||||
* this tab holds is the conversation from before the driver's turn. */
|
||||
export function runHeldElsewhere(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. */
|
||||
/** The driver said how its run is going, which is also its 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.
|
||||
// A tab mid-turn is the authority on its own session; a status message
|
||||
// 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()
|
||||
@@ -88,7 +87,12 @@ export function noteDriverAlive(sessionId: string, planMode: boolean): void {
|
||||
* to perform it. The channel runs this in every tab that loads it, and one with
|
||||
* no runtime would sit in a state nothing can leave. */
|
||||
export function noteRemoteTurnEnded(sessionId: string): void {
|
||||
if (runPosition(sessionId).state !== 'watching') return
|
||||
const state = runPosition(sessionId).state
|
||||
// `idle` counts. A tab that heard the turn end without having heard it start
|
||||
// — it opened between the driver's last status message and its turn-end, or
|
||||
// its handler registered in that window — holds a transcript from before the
|
||||
// turn, and idle is precisely the position that permits driving on it.
|
||||
if (state === 'driving' || state === 'catchingUp') return
|
||||
if (!canCompleteCatchUp()) {
|
||||
positions.delete(sessionId)
|
||||
return
|
||||
@@ -131,6 +135,12 @@ export async function withSessionRunLock<T>(
|
||||
sessionId: string,
|
||||
body: () => Promise<T>
|
||||
): Promise<T | 'busy'> {
|
||||
// Ahead of the lock, because holding the lock is not the same as being
|
||||
// entitled to the conversation: the driver releases it at turn-end while this
|
||||
// tab still owes itself the re-read, and a turn driven in that window sends
|
||||
// the pre-run history the re-read exists to replace. `runHeldElsewhere` and not
|
||||
// `isWatching` for exactly that reason.
|
||||
if (runHeldElsewhere(sessionId)) return '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
|
||||
@@ -164,8 +174,8 @@ export async function withSessionRunLock<T>(
|
||||
* run is visibly on screen, and otherwise go.
|
||||
*
|
||||
* This is deliberately not mutual exclusion, and cannot be made into it. A
|
||||
* driver whose timers have been throttled in a hidden tab stops sending frames
|
||||
* long before its turn ends, so it is reaped as dead and a send from here can
|
||||
* driver whose timers have been throttled in a hidden tab goes silent long
|
||||
* before its turn ends, so it is reaped as dead and a send from here can
|
||||
* start a second turn against the same chat id. Nothing over the channel fixes
|
||||
* that: a probe distinguishes a throttled tab from a closed one, but not a
|
||||
* frozen tab from a closed one, and the browser freezes hidden tabs on much the
|
||||
@@ -174,12 +184,8 @@ export async function withSessionRunLock<T>(
|
||||
* Accepted rather than solved, because the only origins that land here are
|
||||
* served over plain HTTP and are not localhost — a shape used for local testing
|
||||
* rather than for running Windmill. Every HTTPS deployment, and localhost, is a
|
||||
* secure context and takes the real lock above. `isMirroring` and not
|
||||
* `isWatching`: a tab that has moved on to `catchingUp` is still owed its
|
||||
* re-read, and driving from here would send the pre-run history that re-read
|
||||
* exists to replace. */
|
||||
* secure context and takes the real lock above. */
|
||||
async function bestEffort<T>(sessionId: string, body: () => Promise<T>): Promise<T | 'busy'> {
|
||||
if (isMirroring(sessionId)) return 'busy'
|
||||
return await drive(sessionId, body)
|
||||
}
|
||||
|
||||
@@ -195,8 +201,8 @@ async function drive<T>(sessionId: string, body: () => Promise<T>): Promise<T> {
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
* settle a driver that went silent: 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 a silent driver is reaped on
|
||||
// silence alone — and on that path reaching `idle` does entitle this tab to
|
||||
@@ -250,8 +256,8 @@ async function reapDeadDrivers(): Promise<void> {
|
||||
.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.
|
||||
// Re-checked after the await: a status message may have landed while the
|
||||
// query was in flight, and reaping then would tear down a live run.
|
||||
if (await runLockHeld(id)) continue
|
||||
if (!isWatching(id)) continue
|
||||
noteRemoteTurnEnded(id)
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
withSessionRunLock
|
||||
} from './sessionRunOwner.svelte'
|
||||
|
||||
const SESSIONS = ['session-watching', 'session-catching-up', 'session-idle']
|
||||
const SESSIONS = ['session-watching', 'session-catching-up', 'session-idle', 'session-unheard']
|
||||
|
||||
// `positions` is module state and a watching entry keeps the reaper interval
|
||||
// armed, so leave nothing behind for the next suite.
|
||||
@@ -44,6 +44,18 @@ describe('withSessionRunLock with no lock to take', () => {
|
||||
expect(await withSessionRunLock('session-idle', body)).toBe('ran')
|
||||
expect(body).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// A tab that opened between the driver's last status message and its turn-end
|
||||
// never saw the run start, so it is idle — holding the conversation from
|
||||
// before the turn, and idle is the position that permits driving on it.
|
||||
it('refuses a turn-end it never saw start, rather than driving on stale history', async () => {
|
||||
onDriverLost(() => {})
|
||||
noteRemoteTurnEnded('session-unheard')
|
||||
const body = vi.fn(async () => 'ran')
|
||||
|
||||
expect(await withSessionRunLock('session-unheard', body)).toBe('busy')
|
||||
expect(body).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// A fresh module instance is the only honest way to test this: the runtime's
|
||||
@@ -57,7 +69,7 @@ describe('a turn ending in a tab with no session runtime', () => {
|
||||
owner.noteRemoteTurnEnded('session-no-runtime')
|
||||
|
||||
expect(owner.isCatchingUp('session-no-runtime')).toBe(false)
|
||||
expect(owner.isMirroring('session-no-runtime')).toBe(false)
|
||||
expect(owner.runHeldElsewhere('session-no-runtime')).toBe(false)
|
||||
owner.clearRunPosition('session-no-runtime')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,18 +93,15 @@ import { getNonStreamingMetadataCompletion } from '$lib/components/copilot/lib'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { pendingUserAction, type DisplayMessage } from '$lib/components/copilot/chat/shared'
|
||||
import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs'
|
||||
import { canSpliceFrame, mirrorFrameStart, withoutHeavyPayloads } from './sessionMirrorPayload'
|
||||
import {
|
||||
broadcastMirror,
|
||||
broadcastRunStatus,
|
||||
broadcastTurnEnd,
|
||||
MIRROR_THROTTLE_MS,
|
||||
registerSyncHandlers,
|
||||
requestCancel,
|
||||
requestResync,
|
||||
RUN_STATUS_INTERVAL_MS,
|
||||
sendQuestionAnswer,
|
||||
sendToolConfirmation,
|
||||
type MirrorMsg,
|
||||
type MirrorSnapshot
|
||||
type RunStatusMsg
|
||||
} from './sessionSync.svelte'
|
||||
import {
|
||||
clearRunPosition,
|
||||
@@ -938,17 +935,15 @@ async function initRuntime(runtime: SessionRuntime, session: Session) {
|
||||
manager.onChatRotated = (chatId) => setSessionChatId(session.id, chatId)
|
||||
|
||||
// One tab drives a session at a time. The lock brackets the turn so the other
|
||||
// tabs can mirror it, and refuses a second driver rather than letting two
|
||||
// turns interleave into one chat id.
|
||||
// tabs can show that it is running, and refuses a second driver rather than
|
||||
// letting two turns interleave into one chat id.
|
||||
manager.runGuard = async (body) => {
|
||||
const outcome = await withSessionRunLock(session.id, async () => {
|
||||
// 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)
|
||||
startRunStatus(session.id)
|
||||
try {
|
||||
return await body()
|
||||
} finally {
|
||||
stopMirroring(session.id)
|
||||
stopRunStatus(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())
|
||||
@@ -1007,154 +1002,67 @@ async function initRuntime(runtime: SessionRuntime, session: Session) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-tab mirroring
|
||||
// Cross-tab run status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Driving tabs post a frame on this cadence for as long as their turn runs.
|
||||
// A plain interval rather than an $effect on the manager's fields: it also
|
||||
// serves as the liveness heartbeat (a run that stalls in a long tool call
|
||||
// still ticks), and it picks up in-place edits to already-rendered tool cards
|
||||
// that reactive tracking of the array root would miss.
|
||||
const mirrorTimers = new Map<string, ReturnType<typeof setInterval>>()
|
||||
// Driving tabs post their status on this cadence for as long as their turn
|
||||
// runs. A plain interval rather than an $effect on the manager's fields: it
|
||||
// doubles as the liveness heartbeat, so a run stalled in a long tool call still
|
||||
// ticks and is not reaped as a closed tab.
|
||||
const statusTimers = new Map<string, ReturnType<typeof setInterval>>()
|
||||
|
||||
// Where the running turn's own messages start, per session. A frame never
|
||||
// reaches below it, which is what makes overwriting safe: everything at or after
|
||||
// it either is new this turn or is a stripped copy the watcher got from an
|
||||
// earlier frame of this same turn, so a frame can never replace a message the
|
||||
// watcher holds complete from the store. Reset to 0 by a rewrite (compaction),
|
||||
// after which no index is stable and whole transcripts travel instead.
|
||||
const turnStarts = new Map<string, number>()
|
||||
// The driver's transcript length at its last frame, so a shrink is detectable.
|
||||
const lastSentTotals = new Map<string, number>()
|
||||
|
||||
function mirrorSnapshotOf(sessionId: string, full: boolean): MirrorSnapshot | undefined {
|
||||
function postRunStatus(sessionId: string): void {
|
||||
const runtime = runtimes.get(sessionId)
|
||||
if (!runtime) return undefined
|
||||
if (!runtime) return
|
||||
const m = runtime.manager
|
||||
const total = m.displayMessages.length
|
||||
// A shrink means the transcript was rewritten, not appended to: compaction
|
||||
// folds the head into a summary mid-turn. A watcher's prefix is then a
|
||||
// conversation that no longer exists, and no index check can tell — the
|
||||
// lengths still line up. Send the whole thing instead.
|
||||
const rewritten = total < (lastSentTotals.get(sessionId) ?? 0)
|
||||
lastSentTotals.set(sessionId, total)
|
||||
if (rewritten) turnStarts.set(sessionId, 0)
|
||||
// Slice before cloning: `$state.snapshot` walks whatever it is handed, so
|
||||
// snapshotting the whole transcript to send ten messages would traverse the
|
||||
// entire conversation on every tick.
|
||||
const baseIndex = mirrorFrameStart({
|
||||
total,
|
||||
turnStart: turnStarts.get(sessionId) ?? 0,
|
||||
full: full || rewritten
|
||||
})
|
||||
return {
|
||||
broadcastRunStatus({
|
||||
sessionId,
|
||||
chatId: m.historyManager.getCurrentChatId(),
|
||||
baseIndex,
|
||||
// Stripped before the clone, not after: `$state.snapshot` deep-copies
|
||||
// whatever it is handed, so stripping second still copies every image data
|
||||
// URL and pasted file body first — megabytes per tick, several times a
|
||||
// second, for bytes about to be thrown away.
|
||||
tail: $state.snapshot(
|
||||
withoutHeavyPayloads(m.displayMessages.slice(baseIndex))
|
||||
) as DisplayMessage[],
|
||||
total,
|
||||
loading: m.loading,
|
||||
currentReply: m.currentReply,
|
||||
currentReasoning: m.currentReasoning,
|
||||
currentReasoningActive: m.currentReasoningActive,
|
||||
loadingLabel: m.loadingLabel,
|
||||
compacting: m.compacting,
|
||||
blockedOnUser: pendingUserAction(m.displayMessages) !== undefined,
|
||||
loadingLabel: m.loadingLabel,
|
||||
planModeActive: m.planModeActive
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function postMirror(sessionId: string, { full = false } = {}): void {
|
||||
const snap = mirrorSnapshotOf(sessionId, full)
|
||||
if (snap) broadcastMirror(snap)
|
||||
}
|
||||
|
||||
function startMirroring(sessionId: string): void {
|
||||
stopMirroring(sessionId)
|
||||
// Captured before the turn pushes its first message, so it is the index of
|
||||
// the oldest message this turn owns.
|
||||
turnStarts.set(sessionId, runtimes.get(sessionId)?.manager.displayMessages.length ?? 0)
|
||||
postMirror(sessionId)
|
||||
mirrorTimers.set(
|
||||
function startRunStatus(sessionId: string): void {
|
||||
stopRunStatus(sessionId)
|
||||
// Posted immediately: this first message doubles as the "a run started here"
|
||||
// signal, and carries the chat id the watching tabs key their re-read on.
|
||||
postRunStatus(sessionId)
|
||||
statusTimers.set(
|
||||
sessionId,
|
||||
setInterval(() => postMirror(sessionId), MIRROR_THROTTLE_MS)
|
||||
setInterval(() => postRunStatus(sessionId), RUN_STATUS_INTERVAL_MS)
|
||||
)
|
||||
}
|
||||
|
||||
function stopMirroring(sessionId: string): void {
|
||||
const timer = mirrorTimers.get(sessionId)
|
||||
function stopRunStatus(sessionId: string): void {
|
||||
const timer = statusTimers.get(sessionId)
|
||||
if (!timer) return
|
||||
clearInterval(timer)
|
||||
mirrorTimers.delete(sessionId)
|
||||
// One last frame: the closing tokens of a turn usually land between ticks,
|
||||
// and this is what the passive tabs render until their re-read completes.
|
||||
// Posted while the turn's bookkeeping still stands — it is a frame like any
|
||||
// other, and one sent without `turnStart` reaches below the turn and replaces
|
||||
// complete messages with payload-stripped copies, while one sent without
|
||||
// `lastSentTotals` cannot notice a compaction landing on this very tick.
|
||||
postMirror(sessionId)
|
||||
lastSentTotals.delete(sessionId)
|
||||
turnStarts.delete(sessionId)
|
||||
statusTimers.delete(sessionId)
|
||||
}
|
||||
|
||||
/** Adopt a frame from the tab driving this session. */
|
||||
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.
|
||||
/** Adopt the driving tab's status. The transcript is left alone: this tab keeps
|
||||
* showing its own until `turn-end` triggers the re-read. */
|
||||
function applyRunStatus(msg: RunStatusMsg): void {
|
||||
// A tab mid-turn is the authority on its own session; a status message can
|
||||
// only be an echo of a run this tab is itself driving.
|
||||
if (isDriving(msg.sessionId)) return
|
||||
const runtime = runtimes.get(msg.sessionId)
|
||||
if (!runtime) return
|
||||
const m = runtime.manager
|
||||
const onSameChat = m.historyManager.getCurrentChatId() === msg.chatId
|
||||
if (
|
||||
!canSpliceFrame({
|
||||
baseIndex: msg.baseIndex,
|
||||
total: msg.total,
|
||||
localLength: m.displayMessages.length,
|
||||
onSameChat
|
||||
})
|
||||
) {
|
||||
// Nothing here can host this tail: this tab joined mid-run, or the driver
|
||||
// rotated to a chat it isn't on. Ask for the whole transcript instead of
|
||||
// splicing onto a prefix that isn't the same conversation.
|
||||
requestResync(msg.sessionId)
|
||||
return
|
||||
}
|
||||
if (!onSameChat) {
|
||||
// A full snapshot names the conversation it came from, so follow the
|
||||
// driver onto it rather than rendering it under the wrong chat id.
|
||||
m.historyManager.setCurrentChatId(msg.chatId)
|
||||
setSessionChatId(msg.sessionId, msg.chatId)
|
||||
}
|
||||
// Safe to overwrite wholesale: a frame reaches no further back than the
|
||||
// running turn's first message, so everything it replaces is either new or a
|
||||
// stripped copy of its own from an earlier frame — never a message this tab
|
||||
// holds complete from the store.
|
||||
m.displayMessages =
|
||||
msg.baseIndex === 0 ? msg.tail : [...m.displayMessages.slice(0, msg.baseIndex), ...msg.tail]
|
||||
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.loadingLabel = msg.blockedOnUser ? 'Waiting for your answer in the other tab' : msg.loadingLabel
|
||||
}
|
||||
|
||||
/** The driver answers a resync with its whole transcript. */
|
||||
function answerResync(sessionId: string): void {
|
||||
if (!isDriving(sessionId)) return
|
||||
postMirror(sessionId, { full: true })
|
||||
}
|
||||
|
||||
/** A run finished in another tab. The mirror carried the rendered transcript
|
||||
* 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. */
|
||||
/** A run finished in another tab. Nothing of it crossed the channel but its
|
||||
* status, so re-read the record the driver just saved for everything else —
|
||||
* the rendered transcript, 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, attempt = 0): Promise<void> {
|
||||
if (isDriving(sessionId)) return
|
||||
let caughtUp = false
|
||||
@@ -1166,11 +1074,9 @@ async function applyTurnEnd(sessionId: string, chatId: string, attempt = 0): Pro
|
||||
}
|
||||
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.
|
||||
// looks busy, and this one is the driver's, not a turn of our own. These
|
||||
// three are exactly what applyRunStatus sets.
|
||||
m.loading = false
|
||||
m.currentReply = ''
|
||||
m.currentReasoning = ''
|
||||
m.currentReasoningActive = false
|
||||
m.loadingLabel = undefined
|
||||
m.compacting = false
|
||||
const id = chatId || m.historyManager.getCurrentChatId()
|
||||
@@ -1181,10 +1087,10 @@ async function applyTurnEnd(sessionId: string, chatId: string, attempt = 0): Pro
|
||||
const found = await m.historyManager.reloadChat(id)
|
||||
if (found === 'unavailable') {
|
||||
// The store is unreadable *right now*, which says nothing about the
|
||||
// conversation — so this tab still pairs the mirrored transcript with
|
||||
// the history it held before the run. Freeing it here would let a turn
|
||||
// go out under that pair and save it over what the driver wrote. Stay
|
||||
// gated and keep asking; the read is the only thing that can settle it.
|
||||
// conversation — so this tab still holds the one from before the run.
|
||||
// Freeing it here would let a turn go out under that and save it over
|
||||
// what the driver wrote. Stay gated and keep asking; the read is the only
|
||||
// thing that can settle it.
|
||||
scheduleCatchUpRetry(sessionId, chatId, attempt)
|
||||
return
|
||||
}
|
||||
@@ -1218,9 +1124,9 @@ async function applyTurnEnd(sessionId: string, chatId: string, attempt = 0): Pro
|
||||
*
|
||||
* It retries for as long as the runtime lives, including against a store that
|
||||
* will never open. Deliberate: the alternative is giving up and releasing the
|
||||
* gate, and this tab would then send a mirrored transcript paired with pre-run
|
||||
* history — the driver's completed turn missing from what reaches the model,
|
||||
* and its record overwritten. A read every few seconds is the cheaper half of
|
||||
* gate, and this tab would then send the pre-run conversation — the driver's
|
||||
* completed turn missing from what reaches the model, and its record
|
||||
* overwritten. A read every few seconds is the cheaper half of
|
||||
* that trade, and a browser whose IndexedDB never opens has no session history,
|
||||
* artifacts or records either, so a locked composer is not what is broken. */
|
||||
const CATCH_UP_BACKOFF_MS = [300, 700, 1500, 3000, 5000]
|
||||
@@ -1268,8 +1174,7 @@ function applyQuestionAnswer(sessionId: string, toolId: string, choices: string[
|
||||
}
|
||||
|
||||
registerSyncHandlers({
|
||||
onMirror: applyMirror,
|
||||
onResyncRequest: answerResync,
|
||||
onRunStatus: applyRunStatus,
|
||||
onCancelRequest: applyCancelRequest,
|
||||
onToolConfirmation: applyToolConfirmation,
|
||||
onQuestionAnswer: applyQuestionAnswer,
|
||||
@@ -1279,7 +1184,7 @@ registerSyncHandlers({
|
||||
onSessionDelete: (id) => disposeRuntime(id),
|
||||
// Artifacts persist to a store both tabs share, but each keeps its own
|
||||
// reactive copy, so the tab that did not write one never hears of it. That is
|
||||
// what leaves a mirrored plan awaiting approval with no document behind it.
|
||||
// what leaves a plan awaiting approval with no document behind it.
|
||||
onSessionArtifact: (sessionId, artifactId) =>
|
||||
void runtimes.get(sessionId)?.manager.artifacts.applyRemoteArtifact(artifactId)
|
||||
})
|
||||
@@ -1306,10 +1211,10 @@ export function disposeRuntime(sessionId: string) {
|
||||
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, a pending catch-up would
|
||||
// re-read for a manager that is gone, and a leftover position would answer
|
||||
// for a session this tab no longer holds.
|
||||
stopMirroring(sessionId)
|
||||
// status timer with no runtime to read keeps ticking, a pending catch-up
|
||||
// would re-read for a manager that is gone, and a leftover position would
|
||||
// answer for a session this tab no longer holds.
|
||||
stopRunStatus(sessionId)
|
||||
cancelCatchUpRetry(sessionId)
|
||||
clearRunPosition(sessionId)
|
||||
}
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import { BROWSER } from 'esm-env'
|
||||
import { onUserChange, scopedKey } from '$lib/userScopedStorage'
|
||||
import type { DisplayMessage } from '$lib/components/copilot/chat/shared'
|
||||
import { noteDriverAlive, noteRemoteTurnEnded } from './sessionRunOwner.svelte'
|
||||
|
||||
// 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.
|
||||
// carries messages between those copies: record writes, and how the driving
|
||||
// tab's run is going. Who is entitled to drive is sessionRunOwner's question;
|
||||
// this module only tells it what arrived.
|
||||
//
|
||||
// No message carries a transcript. Tabs converge by re-reading the shared
|
||||
// IndexedDB record once a run ends, so what crosses the channel stays small and
|
||||
// bounded whatever a turn produced.
|
||||
//
|
||||
// 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'
|
||||
|
||||
/** 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
|
||||
/** How often a driving tab posts its status. An unchanged run still ticks, so
|
||||
* silence is what tells the other tabs the driver is gone; the reaper's
|
||||
* threshold is several times this. */
|
||||
export const RUN_STATUS_INTERVAL_MS = 1000
|
||||
|
||||
/** 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
|
||||
@@ -36,34 +40,33 @@ type SessionArtifactMsg = { kind: 'session-artifact'; sessionId: string; artifac
|
||||
* 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'
|
||||
/**
|
||||
* How the driving tab's run is going. Deliberately carries no transcript: a
|
||||
* watching tab keeps showing its own, and drives the same loading indicator a
|
||||
* local turn shows from these fields, then re-reads the record once `turn-end`
|
||||
* says the run is over.
|
||||
*
|
||||
* Every field here is a scalar of fixed size. That is the property to preserve:
|
||||
* the transcript this replaced carried tool results and job logs, which are
|
||||
* bounded by nothing, and re-broadcasting them several times a second was a
|
||||
* responsiveness bug that recurred once per field anyone forgot to strip.
|
||||
*/
|
||||
type RunStatusMsg = {
|
||||
kind: 'run-status'
|
||||
sessionId: string
|
||||
chatId: string
|
||||
/** Index the tail starts at; the receiver keeps its own prefix below it, and
|
||||
* asks for a full snapshot when it has no prefix that reaches this far. */
|
||||
baseIndex: number
|
||||
tail: DisplayMessage[]
|
||||
/** The driver's whole transcript length. A watcher holding more than this has
|
||||
* a prefix the driver no longer has, so its splice would invent a transcript
|
||||
* that never existed; it resyncs instead. */
|
||||
total: number
|
||||
loading: boolean
|
||||
currentReply: string
|
||||
currentReasoning: string
|
||||
currentReasoningActive: boolean
|
||||
loadingLabel: string | undefined
|
||||
compacting: boolean
|
||||
/** Parked on a question that only the driving tab can render. The watcher
|
||||
* says where to answer it rather than implying work is in progress. */
|
||||
blockedOnUser: boolean
|
||||
loadingLabel: string | undefined
|
||||
/** The driver's plan-mode posture. The only autonomy state worth carrying:
|
||||
* every other one is a stored preference each tab keeps its own copy of,
|
||||
* while plan mode is never persisted and so exists nowhere but the driving
|
||||
* tab's memory. */
|
||||
planModeActive: boolean
|
||||
}
|
||||
/** Sent by a tab whose local prefix can't host the tail it just received (it
|
||||
* joined mid-run, or is on a different chat). The driver answers with a full
|
||||
* snapshot. */
|
||||
type ResyncRequestMsg = { kind: 'resync-request'; sessionId: string }
|
||||
/** A Stop pressed in a tab that is only watching the run. */
|
||||
type CancelRequestMsg = { kind: 'cancel-request'; sessionId: string }
|
||||
/** A run blocked on the user is unblocked from whichever tab the user is in;
|
||||
@@ -86,8 +89,7 @@ type SyncMsg =
|
||||
| SessionDeleteMsg
|
||||
| SessionArtifactMsg
|
||||
| TurnEndMsg
|
||||
| MirrorMsg
|
||||
| ResyncRequestMsg
|
||||
| RunStatusMsg
|
||||
| CancelRequestMsg
|
||||
| ToolConfirmationMsg
|
||||
| QuestionAnswerMsg
|
||||
@@ -97,8 +99,7 @@ type Handlers = {
|
||||
onSessionDelete: (id: string) => void
|
||||
onSessionArtifact: (sessionId: string, artifactId: string) => void
|
||||
onTurnEnd: (sessionId: string, chatId: string) => void
|
||||
onMirror: (msg: MirrorMsg) => void
|
||||
onResyncRequest: (sessionId: string) => void
|
||||
onRunStatus: (msg: RunStatusMsg) => void
|
||||
onCancelRequest: (sessionId: string) => void
|
||||
onToolConfirmation: (sessionId: string, toolId: string, confirmed: boolean) => void
|
||||
onQuestionAnswer: (sessionId: string, toolId: string, choices: string[]) => void
|
||||
@@ -172,12 +173,9 @@ function receive(msg: SyncMsg): void {
|
||||
noteRemoteTurnEnded(msg.sessionId)
|
||||
emit('onTurnEnd', msg.sessionId, msg.chatId)
|
||||
break
|
||||
case 'mirror':
|
||||
case 'run-status':
|
||||
noteDriverAlive(msg.sessionId, msg.planModeActive)
|
||||
emit('onMirror', msg)
|
||||
break
|
||||
case 'resync-request':
|
||||
emit('onResyncRequest', msg.sessionId)
|
||||
emit('onRunStatus', msg)
|
||||
break
|
||||
case 'cancel-request':
|
||||
emit('onCancelRequest', msg.sessionId)
|
||||
@@ -218,17 +216,13 @@ export function broadcastSessionArtifact(sessionId: string, artifactId: string):
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live mirroring
|
||||
// Run status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function broadcastTurnEnd(sessionId: string, chatId: string): void {
|
||||
post({ kind: 'turn-end', sessionId, chatId })
|
||||
}
|
||||
|
||||
export function requestResync(sessionId: string): void {
|
||||
post({ kind: 'resync-request', sessionId })
|
||||
}
|
||||
|
||||
export function requestCancel(sessionId: string): void {
|
||||
post({ kind: 'cancel-request', sessionId })
|
||||
}
|
||||
@@ -241,11 +235,11 @@ export function sendQuestionAnswer(sessionId: string, toolId: string, choices: s
|
||||
post({ kind: 'question-answer', sessionId, toolId, choices })
|
||||
}
|
||||
|
||||
export type MirrorSnapshot = Omit<MirrorMsg, 'kind'>
|
||||
export type RunStatus = Omit<RunStatusMsg, 'kind'>
|
||||
|
||||
/** Send the driver's current view of a run. */
|
||||
export function broadcastMirror(snap: MirrorSnapshot): void {
|
||||
post({ kind: 'mirror', ...snap })
|
||||
/** Say how the run this tab is driving is going. */
|
||||
export function broadcastRunStatus(status: RunStatus): void {
|
||||
post({ kind: 'run-status', ...status })
|
||||
}
|
||||
|
||||
export type { MirrorMsg }
|
||||
export type { RunStatusMsg }
|
||||
|
||||
Reference in New Issue
Block a user