fix(ai-sessions): refuse programmatic queueing while another tab drives

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-08-27 14:44:37 +02:00
co-authored by Claude Opus 5
parent 215f354bef
commit 2a90cb23ff
4 changed files with 43 additions and 6 deletions
@@ -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>>()