mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
fix(ai-sessions): refuse programmatic queueing while another tab drives
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
215f354bef
commit
2a90cb23ff
@@ -1781,13 +1781,25 @@ export class AIChatManager {
|
||||
/** Queue the message typed while a turn is streaming. There is only ever
|
||||
* one queued message; pressing Enter again appends the new text as another
|
||||
* line so it all goes out as a single message, and its images accumulate
|
||||
* alongside it. */
|
||||
* alongside it.
|
||||
*
|
||||
* Refused outright while another tab's run is on screen. `loading` is that
|
||||
* tab's, and the turn that would drain this queue belongs to it, so anything
|
||||
* parked here waits for an unrelated later turn of our own and then fires an
|
||||
* instruction written against a workspace that has moved on. The composer is
|
||||
* locked for the same reason; this is the same rule for the senders that
|
||||
* never touch a composer — an editor's AI Fix, a raw-app inline prompt, an
|
||||
* arriving hand-off. */
|
||||
queueMessage(
|
||||
text: string,
|
||||
images: AttachedImage[] = [],
|
||||
context?: ContextElement[],
|
||||
files: AttachedTextFile[] = []
|
||||
) {
|
||||
if (this.mirroringRemoteRun) {
|
||||
sendUserToast('This session is running in another tab. Try again when it finishes.', true)
|
||||
return
|
||||
}
|
||||
const trimmed = text.trim()
|
||||
// An attachment-only or context-only draft is still a message; only a fully
|
||||
// empty send is ignored (mirrors the idle empty-send guard).
|
||||
|
||||
@@ -416,6 +416,24 @@ describe('AIChatManager.sendOrQueue', () => {
|
||||
expect(manager.queuedMessage).toBe('')
|
||||
})
|
||||
|
||||
// `loading` while another tab drives is that tab's, and the turn that drains
|
||||
// this queue is its turn, not ours. Anything parked here would sit until some
|
||||
// unrelated later turn of our own picked it up and ran an instruction written
|
||||
// against a workspace that had moved on. The composer is locked for the same
|
||||
// reason; these senders never touch a composer.
|
||||
it('refuses to queue a programmatic prompt while another tab drives', () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.isSessionChat = true
|
||||
manager.sessionId = 'session-programmatic-queue'
|
||||
noteDriverAlive('session-programmatic-queue', false)
|
||||
manager.loading = true
|
||||
|
||||
manager.sendOrQueue('deploy the fix')
|
||||
|
||||
expect(mocks.runChatLoop).not.toHaveBeenCalled()
|
||||
expect(manager.queuedMessage).toBe('')
|
||||
})
|
||||
|
||||
// `loading` only rises after a send's attachment upkeep, so gating on it alone
|
||||
// leaves a window where a second programmatic send slips through.
|
||||
it('queues during a send that has not reached loading yet', async () => {
|
||||
|
||||
@@ -193,10 +193,9 @@ async function drive<T>(sessionId: string, body: () => Promise<T>): Promise<T> {
|
||||
* 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. That only ever frees the UI: reaching `idle` does not by
|
||||
// itself entitle this tab to drive, because {@link bestEffort} probes for a
|
||||
// live driver at the moment it matters rather than trusting a conclusion
|
||||
// drawn from silence up to ten seconds earlier.
|
||||
// silence alone — and on that path reaching `idle` does entitle this tab to
|
||||
// drive, with everything that implies when the driver was merely throttled.
|
||||
// See {@link bestEffort} for why that is accepted rather than defended.
|
||||
if (!EXCLUSIVE_OWNERSHIP) return false
|
||||
try {
|
||||
const state = await navigator.locks.query()
|
||||
|
||||
@@ -1214,7 +1214,15 @@ async function applyTurnEnd(sessionId: string, chatId: string, attempt = 0): Pro
|
||||
}
|
||||
|
||||
/** Backoff for a catch-up that could not read the store, capped so a long
|
||||
* outage settles into polling rather than growing without bound. */
|
||||
* outage settles into polling rather than growing without bound.
|
||||
*
|
||||
* 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
|
||||
* 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]
|
||||
const catchUpRetries = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user