diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts index ff4e690677e..e565955abcd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts @@ -94,6 +94,31 @@ describe('structured agent session eviction', () => { ]) }) + it('still stops the child when the pre-stop snapshot cannot drain the sink', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const ctx = context() + const snapshot = vi.fn() + ctx.beforeProviderChildStop = snapshot + // A journal write that never settles, then a healthy sink once the child is stopped. + ctx.eventSink.drained = vi + .fn() + .mockReturnValueOnce(new Promise(() => {})) + .mockResolvedValue({ + ok: true + }) as unknown as StructuredAgentSessionEvictionContext['eventSink']['drained'] + + const eviction = evictStructuredAgentSession(ctx) + await vi.advanceTimersByTimeAsync(1_000) + + expect(snapshot).toHaveBeenCalledOnce() + expect(ctx.adapter.closeSession).toHaveBeenCalledOnce() + await eviction + } finally { + vi.useRealTimers() + } + }) + it('aborts after a failed drain barrier without unbinding or forgetting the session', async () => { const ctx = context() ctx.eventSink.drained = vi.fn(async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts index ab22f5c41d8..951bbd2ab32 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts @@ -23,6 +23,7 @@ import { type StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { withTimeout } from '../../../shared/promise-timeout-fallback' export type StructuredAgentSessionEvictionContext = { sessionId: string @@ -50,6 +51,9 @@ export type StructuredAgentSessionEvictionContext = { releaseLease: () => Promise } +/** The resume offer is advisory; a stalled sink must not hold the child's stop behind it. */ +const SNAPSHOT_DRAIN_TIMEOUT_MS = 1_000 + export type StructuredAgentSessionEvictionStep = { name: string run: (context: StructuredAgentSessionEvictionContext) => Promise | void @@ -65,7 +69,7 @@ export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSe return } // Events the provider already delivered are part of what the sidebar showed at the stop. - await context.eventSink.drained() + await withTimeout(context.eventSink.drained(), SNAPSHOT_DRAIN_TIMEOUT_MS, null) try { context.beforeProviderChildStop() } catch {