Skip to content

Commit 12550b9

Browse files
authored
fix(sessions): close the delegation dialog without waiting for sessions to start (#1631)
* fix(sessions): close the delegation dialog without waiting for sessions to start Start and immediate model changes answered only after every delegated session had its worktree created and its CLI started (or stopped and restarted), and the dialog ignored Close until then. The UI routes now answer once the batch or change is accepted; the launch continues in the background and reports through the batch and change state. The model's own CreateSessions call still waits, since it needs the new session ids. Closing the dialog is never blocked by an in-flight request. * test(sessions): wait for both first messages before checking a launched batch
1 parent 99a4e8c commit 12550b9

8 files changed

Lines changed: 130 additions & 14 deletions

File tree

‎desktop/src/components/sessionDelegation/SessionDelegationDialog.test.tsx‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,31 @@ describe('SessionDelegationDialog', () => {
8282
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
8383
})
8484

85+
it('closes while a confirmation is still in flight and stays closed when it answers', async () => {
86+
let answer!: (batch: SessionDelegationSnapshot['batches'][number]) => void
87+
vi.mocked(sessionCollaborationApi.act).mockReturnValueOnce(new Promise(resolve => { answer = resolve }))
88+
openDialog(fixture())
89+
fireEvent.click(screen.getByRole('button', { name: 'Start sessions' }))
90+
await waitFor(() => expect(sessionCollaborationApi.act).toHaveBeenCalledWith('parent', expect.objectContaining({ id: 'batch' }), 'confirm'))
91+
expect(screen.getByRole('button', { name: 'Start sessions' })).toBeDisabled()
92+
fireEvent.click(screen.getByRole('button', { name: 'Close dialog' }))
93+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
94+
expect(useSessionDelegationStore.getState().bySession.parent?.manageOpen).toBe(false)
95+
await act(async () => { answer({ ...fixture().batches[0]!, state: 'launching', revision: 2 }) })
96+
await waitFor(() => expect(sessionCollaborationApi.get).toHaveBeenCalled())
97+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
98+
})
99+
100+
it('closes with Escape while a model change is still in flight', async () => {
101+
vi.mocked(sessionCollaborationApi.change).mockReturnValueOnce(new Promise(() => {}))
102+
openDialog(launchedFixture())
103+
fireEvent.click(screen.getByLabelText('Builder'))
104+
fireEvent.click(screen.getByRole('button', { name: 'Apply to 1 session' }))
105+
await waitFor(() => expect(screen.getByRole('button', { name: 'Apply to 1 session' })).toBeDisabled())
106+
fireEvent.keyDown(document, { key: 'Escape' })
107+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
108+
})
109+
85110
it('blocks unresolved models and can cancel without starting', async () => {
86111
const data = fixture()
87112
data.batches[0]!.sessions[0]!.runtime = { providerId: 'deleted', modelId: 'same' }

‎desktop/src/components/sessionDelegation/SessionDelegationDialog.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ function SessionDelegationDialogContent({ sessionId }: { sessionId: string }) {
7878
const effective = (row: DelegationEntryView) => drafts[row.key] ?? row.entry.runtime
7979
const conflicted = (batch: SessionDelegationBatch) => draftRevisions.current.has(batch.id) && draftRevisions.current.get(batch.id) !== batch.revision
8080

81-
const close = () => { if (!busy) setManageOpen(sessionId, false) }
81+
// Closing never cancels a request; its outcome shows in the review bar and the Activity rail.
82+
const close = () => setManageOpen(sessionId, false)
8283
/**
8384
* Forget what a successful action submitted, and close only when nothing else
8485
* is left. A selection can mix started sessions, which Apply changes, with

‎scripts/quality-gate/mock-llm/scenarios.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,16 @@ export function createMockOnlyRunners(
103103
const response = await fetch(`${base}/batches/${encodeURIComponent(batch.id)}/confirm`, {
104104
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedRevision: batch.revision }),
105105
})
106-
const launched = await response.json() as any
107-
if (!response.ok || launched.state !== 'launched' || !launched.sessions[0]?.sessionId) throw new Error(`confirm did not launch the child: ${JSON.stringify(launched)}`)
106+
const accepted = await response.json() as any
107+
if (!response.ok || !['launching', 'launched'].includes(accepted.state)) throw new Error(`confirm was not accepted: ${JSON.stringify(accepted)}`)
108+
// The UI request answers once the batch is launching; the child starts after it.
109+
const deadline = Date.now() + ctx.stepTimeoutMs
110+
let launched = accepted
111+
while (launched.state !== 'launched' && launched.state !== 'failed' && Date.now() < deadline) {
112+
await Bun.sleep(100)
113+
launched = (await read()).batches.find((item: any) => item.id === batch.id) ?? launched
114+
}
115+
if (launched.state !== 'launched' || !launched.sessions[0]?.sessionId) throw new Error(`confirm did not launch the child: ${JSON.stringify(launched)}`)
108116
const runtime = launched.sessions[0].runtime
109117
if (runtime.providerId !== ctx.target.providerId || runtime.modelId !== ctx.target.modelId) throw new Error('Child runtime did not preserve the selected provider and model')
110118
await fetch(`${base}/stop`, { method: 'POST' })

‎src/server/__tests__/e2e/session-collaboration.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,10 @@ test('desktop host dispatches independent sessions, exchanges messages, exposes
6363
expect(proposals.every(batch => batch.state === 'review' && batch.sessions.every((item: any) => !item.sessionId))).toBe(true)
6464
const children = []
6565
for (const batch of proposals) {
66-
const confirmed = await api(`/api/session-collaboration/${root.sessionId}/batches/${encodeURIComponent(batch.id)}/confirm`, { expectedRevision: batch.revision })
67-
expect(confirmed.state).toBe('launched')
68-
children.push(confirmed.sessions[0])
66+
const accepted = await api(`/api/session-collaboration/${root.sessionId}/batches/${encodeURIComponent(batch.id)}/confirm`, { expectedRevision: batch.revision })
67+
expect(['launching', 'launched']).toContain(accepted.state)
68+
const snapshot = await eventually(() => api(`/api/session-collaboration/${root.sessionId}/delegation`), value => value.batches.some((item: any) => item.id === batch.id && item.state === 'launched'), 'batch launched')
69+
children.push(snapshot.batches.find((item: any) => item.id === batch.id).sessions[0])
6970
}
7071
expect(new Set([root.sessionId, ...children.map(child => child.sessionId)]).size).toBe(3)
7172
const busyDelivery = await tool('send', { targetSessionId: children[0].sessionId, content: 'Progress while running', messageId: 'busy-message' })

‎src/server/api/sessionCollaboration.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ test('UI model changes validate input and bind the route session', async () => {
6666
const input = { requestId: 'change', mode: 'after_turn', selections: [{ sessionId: 'child', runtime: { providerId: 'b', modelId: 'shared' } }] }
6767
const response = await handleSessionCollaborationUiApi(new Request(url, { method: 'POST', body: JSON.stringify(input) }), url, service)
6868
expect(response.status).toBe(200)
69-
expect(calls).toEqual([['parent', input]])
69+
expect(calls).toEqual([['parent', input, { wait: false }]])
7070
const invalid = await handleSessionCollaborationUiApi(new Request(url, { method: 'POST', body: '{' }), url, service)
7171
expect(invalid.status).toBe(400)
7272
})

‎src/server/api/sessionCollaboration.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,12 @@ export async function handleSessionCollaborationUiApi(req: Request, url: URL, se
9999
return Response.json(await service.updateBatch(sessionId, batchId, input.expectedRevision, input.selections))
100100
}
101101
const input = parse(revisionSchema, body)
102-
if (parts[3] === 'confirm') return Response.json(await service.confirmBatch(sessionId, batchId, input.expectedRevision))
102+
// The dialog closes on this response; the launch reports through the batch's state.
103+
if (parts[3] === 'confirm') return Response.json(await service.confirmBatch(sessionId, batchId, input.expectedRevision, { wait: false }))
103104
if (parts[3] === 'cancel') return Response.json(await service.cancelBatch(sessionId, batchId, input.expectedRevision))
104105
}
105106
if (parts.length === 2 && parts[1] === 'model-changes' && req.method === 'POST') {
106-
return Response.json(await service.changeModels(sessionId, parse(changeSchema, await jsonBody(req))))
107+
return Response.json(await service.changeModels(sessionId, parse(changeSchema, await jsonBody(req)), { wait: false }))
107108
}
108109
if (parts.length === 4 && parts[1] === 'model-changes' && parts[3] === 'cancel' && req.method === 'POST') {
109110
const input = parse(revisionSchema, await jsonBody(req))

‎src/server/services/sessionCollaborationService.delegation.test.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { join } from 'node:path'
44
import { tmpdir } from 'node:os'
55
import { SessionCollaborationService, type SessionCollaborationDependencies, type CollaborationMessage } from './sessionCollaborationService.js'
66
import type { SessionRuntimeSelection } from '../../shared/sessionDelegation.js'
7+
import { handleSessionCollaborationUiApi } from '../api/sessionCollaboration.js'
78

89
let directory: string
910
let service: SessionCollaborationService
@@ -344,3 +345,66 @@ test('manual child Stop fences a model change still validating before persistenc
344345
expect(await service.listModelChanges('root')).toEqual([])
345346
expect((await service.status()).members.find(item => item.sessionId === child.sessionId)?.stopped).toBe(true)
346347
})
348+
349+
async function until<T>(read: () => Promise<T>, ready: (value: T) => boolean): Promise<T> {
350+
for (let attempt = 0; attempt < 200; attempt++) {
351+
const value = await read()
352+
if (ready(value)) return value
353+
await Bun.sleep(5)
354+
}
355+
throw new Error('condition was not reached')
356+
}
357+
const uiPost = (path: string, body: unknown) => {
358+
const url = new URL(`http://localhost/api/session-collaboration/root/${path}`)
359+
return handleSessionCollaborationUiApi(new Request(url, { method: 'POST', body: JSON.stringify(body) }), url, service)
360+
}
361+
362+
// The dialog closes on the response. It used to arrive only after every
363+
// workspace was created and every CLI had started, with Close ignored meanwhile.
364+
test('UI Start answers once the batch is launching and the launch finishes on its own', async () => {
365+
let release!: () => void
366+
const gate = new Promise<void>(resolve => { release = resolve })
367+
const create = deps.sessions.create
368+
deps.sessions.create = async (...args) => { await gate; return create(...args) }
369+
// The batch reads launched before each first message is delivered; a slow
370+
// start keeps that window open on every machine.
371+
deps.runtime.start = async (_, message) => { await Bun.sleep(20); started.push(message) }
372+
const proposal = await service.propose('root', input())
373+
const response = await uiPost(`batches/${encodeURIComponent(proposal.id)}/confirm`, { expectedRevision: proposal.revision })
374+
expect(response.status).toBe(200)
375+
expect(await response.json()).toMatchObject({ id: proposal.id, state: 'launching' })
376+
expect(created).toBe(0)
377+
release()
378+
await until(async () => started.length, count => count === 2)
379+
const [launched] = await service.listBatches('root')
380+
expect(launched?.state).toBe('launched')
381+
expect(launched?.sessions.every(entry => entry.launched && entry.sessionId)).toBe(true)
382+
})
383+
384+
test('UI Start still refuses a stale revision, and a launch failing later is recorded on the batch', async () => {
385+
let release!: () => void
386+
const gate = new Promise<void>(resolve => { release = resolve })
387+
deps.sessions.create = async () => { await gate; throw new Error('workspace failure') }
388+
const proposal = await service.propose('root', input())
389+
expect((await uiPost(`batches/${encodeURIComponent(proposal.id)}/confirm`, { expectedRevision: proposal.revision + 1 })).status).toBe(409)
390+
expect((await uiPost(`batches/${encodeURIComponent(proposal.id)}/confirm`, { expectedRevision: proposal.revision })).status).toBe(200)
391+
release()
392+
const [failed] = await until(() => service.listBatches('root'), batches => batches[0]?.state === 'failed')
393+
expect(failed?.error).toContain('workspace failure')
394+
expect(started).toHaveLength(0)
395+
})
396+
397+
test('UI immediate change answers before running sessions are stopped and restarted', async () => {
398+
const child = await service.create('root', { prompt: 'work', runtime })
399+
await service.onMessageConsumed(child.messageId)
400+
let release!: () => void
401+
const gate = new Promise<void>(resolve => { release = resolve })
402+
deps.runtime.stopAndWait = async id => { await gate; await service.onStopped(id) }
403+
const response = await uiPost('model-changes', { requestId: 'ui', mode: 'immediate', selections: [{ sessionId: child.sessionId, runtime: { ...runtime, modelId: 'better' } }] })
404+
expect(response.status).toBe(200)
405+
expect(['pending', 'applying']).toContain((await response.json()).state)
406+
expect(applied).toEqual([])
407+
release()
408+
await until(() => service.listModelChanges('root'), changes => changes[0]?.state === 'applied')
409+
expect(applied).toEqual([child.sessionId])
410+
})

‎src/server/services/sessionCollaborationService.ts‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ export type SessionCollaborationDependencies = {
7272
}
7373
now?: () => Date
7474
}
75-
type Store = { batches?: Record<string, SessionDelegationBatch & { input: string; stopEpoch: number }>; modelChanges?: Record<string, SessionModelChange & { input: string; continuations?: string[]; stopEpoch?: number }>; version: 1; revision: number; members: Record<string, CollaborationMember>; messages: CollaborationMessage[]; stopEpochs?: Record<string, number>; creations?: Record<string, { input: string; rootSessionId?: string; stopEpoch?: number; result?: { sessionId: string; workDir?: string; messageId: string; title?: string }; failure?: { message: string; code: string; status: number } }> }
75+
type StoredBatch = SessionDelegationBatch & { input: string; stopEpoch: number }
76+
type Store = { batches?: Record<string, StoredBatch>; modelChanges?: Record<string, SessionModelChange & { input: string; continuations?: string[]; stopEpoch?: number }>; version: 1; revision: number; members: Record<string, CollaborationMember>; messages: CollaborationMessage[]; stopEpochs?: Record<string, number>; creations?: Record<string, { input: string; rootSessionId?: string; stopEpoch?: number; result?: { sessionId: string; workDir?: string; messageId: string; title?: string }; failure?: { message: string; code: string; status: number } }> }
7677
/** Pages one ReadSession cursor chain may serve before it stops instead of walking the whole transcript. */
7778
export const COLLABORATION_READ_MAX_PAGES = 8
7879
export const COLLABORATION_WAIT_MIN_MS = 10_000
@@ -334,7 +335,13 @@ export class SessionCollaborationService {
334335
return (await this.listBatches(callerSessionId)).find(item => item.id === id)!
335336
}
336337

337-
async confirmBatch(callerSessionId: string, id: string, revision: number): Promise<SessionDelegationBatch> {
338+
/**
339+
* The model's tool call waits for the launch: it needs the new session ids.
340+
* The UI passes `wait: false` and returns once the batch is launching.
341+
* Creating each workspace and starting its CLI can take far longer than a
342+
* dialog should stay open, and the batch's state already reports the outcome.
343+
*/
344+
async confirmBatch(callerSessionId: string, id: string, revision: number, options: { wait?: boolean } = {}): Promise<SessionDelegationBatch> {
338345
if (!this.deps.models) throw ApiError.conflict('Model selection is unavailable')
339346
await this.ready; await this.tail
340347
const snapshot = structuredClone(this.batch(callerSessionId, id))
@@ -352,6 +359,13 @@ export class SessionCollaborationService {
352359
batch.state = 'launching'; batch.revision++; delete batch.error
353360
batch.sessions.forEach((item, index) => { item.runtime = runtimes[index]! })
354361
})
362+
const launch = this.launchBatch(callerSessionId, id, snapshot, runtimes)
363+
if (options.wait === false) launch.catch(error => console.error('[SessionCollaboration] Batch launch failed', error))
364+
else await launch
365+
return (await this.listBatches(callerSessionId)).find(item => item.id === id)!
366+
}
367+
368+
private async launchBatch(callerSessionId: string, id: string, snapshot: StoredBatch, runtimes: SessionRuntimeSelection[]): Promise<void> {
355369
try {
356370
for (const entry of snapshot.sessions) {
357371
const runtime = runtimes[snapshot.sessions.indexOf(entry)]!
@@ -388,10 +402,10 @@ export class SessionCollaborationService {
388402
} catch (cause) {
389403
await this.mutate(() => { const batch = this.batch(callerSessionId, id); batch.state = 'failed'; batch.revision++; batch.error = cause instanceof Error ? cause.message : String(cause) })
390404
}
391-
return (await this.listBatches(callerSessionId)).find(item => item.id === id)!
392405
}
393406

394-
async changeModels(callerSessionId: string, input: SessionModelChangeInput): Promise<SessionModelChange> {
407+
/** As with confirmBatch, the UI passes `wait: false`: stopping and restarting sessions reports through the change's state. */
408+
async changeModels(callerSessionId: string, input: SessionModelChangeInput, options: { wait?: boolean } = {}): Promise<SessionModelChange> {
395409
if (!input.requestId?.trim() || !Array.isArray(input.selections) || !input.selections.length || input.selections.length > 8 || new Set(input.selections.map(item => item.sessionId)).size !== input.selections.length) throw ApiError.badRequest('Choose between one and eight distinct sessions')
396410
if (!this.deps.models || !this.deps.runtime.apply || !this.deps.runtime.stopAndWait) throw ApiError.conflict('Runtime changes are unavailable')
397411
if (input.mode !== undefined && input.mode !== 'after_turn' && input.mode !== 'immediate') throw ApiError.badRequest('Invalid change mode')
@@ -430,7 +444,9 @@ export class SessionCollaborationService {
430444
this.store.modelChanges ??= {}
431445
this.store.modelChanges[id] = { id, callerSessionId, requestId: input.requestId, revision: 1, mode: input.mode ?? 'after_turn', resume: input.resume !== false, selections, state: 'pending', input: JSON.stringify(input), stopEpoch: this.store.stopEpochs?.[caller.rootSessionId] ?? 0 }
432446
})
433-
await this.processPendingModelChanges()
447+
const processing = this.processPendingModelChanges()
448+
if (options.wait === false) processing.catch(error => console.error('[SessionCollaboration] Model change failed', error))
449+
else await processing
434450
return (await this.listModelChanges(callerSessionId)).find(item => item.id === id)!
435451
}
436452

0 commit comments

Comments
 (0)