fix(web): honor search ownership and cancellation

This commit is contained in:
Hypatia May
2026-07-27 12:42:46 +08:00
parent 222096e3cf
commit 31215687f9
9 changed files with 75 additions and 18 deletions

View File

@@ -41,6 +41,9 @@ const DEFAULT_MAX_MESSAGES = 50
/** Product contract: sidebar search returns one bounded page and no cursor. */
const SESSION_SEARCH_LIMIT = 20
/** Bound cold-log stat fan-out so an aborted search stops launching new work. */
const COLD_SUMMARY_BATCH_SIZE = 16
/** Surface message event types (the pagination counting unit). */
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
@@ -170,15 +173,22 @@ function summarize(session: Session, running: boolean): SessionSummary {
* updatedAt is the log file's mtime; backends without a per-session file
* (locate() undefined) fall back to the header's createdAt.
*/
async function summarizeCold(persistence: SessionPersistence, meta: SessionHeader): Promise<SessionSummary> {
async function summarizeCold(
persistence: SessionPersistence,
meta: SessionHeader,
signal?: AbortSignal,
): Promise<SessionSummary> {
signal?.throwIfAborted()
let updatedAt = meta.createdAt
const location = persistence.locate(meta)
signal?.throwIfAborted()
if (location !== undefined) {
try {
updatedAt = (await stat(location.path)).mtimeMs
} catch {
// The log vanished between list() and stat() (concurrent cleanup); createdAt stands in.
}
signal?.throwIfAborted()
}
return {
sessionId: meta.id,
@@ -575,16 +585,27 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
* Attached sessions come from memory; servable cold sessions merge from
* persistence, and the final order is newest-first.
*/
async function listVisibleSessionSummaries(): Promise<SessionSummary[]> {
async function listVisibleSessionSummaries(signal?: AbortSignal): Promise<SessionSummary[]> {
signal?.throwIfAborted()
const items = ctx.sessions.list().map((session) => {
const agent = ctx.agents.get(session.id)
return summarize(session, agent?.status === 'running')
})
signal?.throwIfAborted()
const attached = new Set(items.map(item => item.sessionId))
const persistence = ctx.get('sessionPersistence')
if (persistence !== undefined) {
const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
items.push(...await Promise.all(cold.map(meta => summarizeCold(persistence, meta))))
const cold = (await persistence.list(signal))
.filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
signal?.throwIfAborted()
for (let offset = 0; offset < cold.length; offset += COLD_SUMMARY_BATCH_SIZE) {
signal?.throwIfAborted()
const batch = cold.slice(offset, offset + COLD_SUMMARY_BATCH_SIZE)
items.push(...await Promise.all(
batch.map(meta => summarizeCold(persistence, meta, signal)),
))
signal?.throwIfAborted()
}
}
items.sort((a, b) => b.updatedAt - a.updatedAt)
return items
@@ -616,7 +637,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})
}
try {
const visible = await listVisibleSessionSummaries()
const visible = await listVisibleSessionSummaries(signal)
if (isAborted(signal)) return cancelled()
if (visible.length === 0) return ok(request, { items: [], hasMore: false })
const visibleIds = new Set(visible.map(item => item.sessionId))

View File

@@ -220,6 +220,40 @@ describe('session.search', () => {
expect(response.result.value.items.at(-1)?.sessionId).toBe('visible-19')
})
it('propagates cancellation through visible-session collection and stops cold-summary work', async () => {
const ctx = await baseContext()
const controller = new AbortController()
const cold = Array.from({ length: 32 }, (_, index) => header(`cold-${index}`, `/cold-${index}`))
const list = vi.fn((signal?: AbortSignal) => {
expect(signal).toBe(controller.signal)
return Promise.resolve(cold)
})
let locateCalls = 0
ctx.provide('sessionPersistence', {
list,
locate: () => {
locateCalls++
controller.abort()
return undefined
},
} as never)
const searchSessions = vi.fn()
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('cancel-during-visibility'),
controller.signal,
)
expect(response.result).toMatchObject({
ok: false,
error: { code: 'cancelled' },
})
expect(list).toHaveBeenCalledOnce()
expect(locateCalls).toBe(1)
expect(searchSessions).not.toHaveBeenCalled()
})
it('maps missing composition, query cancellation, and provider failure', async () => {
const missingCtx = await baseContext()
missingCtx.sessions.create(sid('visible'), { meta: header('visible') })