fix(router): forward force-close lifecycle

This commit is contained in:
Merge Sim
2026-09-02 16:48:44 -07:00
parent 5aafdc4209
commit dbcf2d79dd
3 changed files with 107 additions and 3 deletions
@@ -11,6 +11,8 @@ import {
} from './codex-structured-session-adapter'
import { handleCodexSessionExit } from './codex-structured-session-close'
import type { CodexSession } from './codex-structured-session-state'
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router'
const THREAD = 'thread-1'
@@ -60,6 +62,16 @@ function adapterFixture() {
return { adapter, connections, events }
}
function claudeAdapterStub(): StructuredAgentSessionAdapter {
return {
acquire: vi.fn(async () => ({ process: { pid: 1 } }) as never),
dispatch: vi.fn(),
cancelTurn: vi.fn(),
answerPrompt: vi.fn(),
setOption: vi.fn()
}
}
describe('Codex structured session close lifecycle', () => {
it('forwards a one-shot exit when lifecycle admission is rejected', () => {
const connection: CodexAppServerConnection = {
@@ -164,4 +176,27 @@ describe('Codex structured session close lifecycle', () => {
{ cause: 'unexpected-exit', reason: 'sink failed', fence: 7 }
])
})
it('routes Codex sink-failure recovery through force-close and preserves unexpected-exit settlement', async () => {
const { adapter, connections, events } = adapterFixture()
const router = new StructuredAgentSessionAdapterRouter(
{ claude: claudeAdapterStub(), codex: adapter },
async () => {}
)
await router.acquire({ identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' })
const current = connections[0]
if (!current) {
throw new Error('missing connection')
}
current.connection.close = async () => {
current.handlers.onExit?.(new Error('journal sink failed'))
return true
}
const forceCloseSession = router.forceCloseSession
await expect(forceCloseSession('session-1')).resolves.toBe(true)
expect(events.filter((event) => event.type === 'ended')).toMatchObject([
{ cause: 'unexpected-exit', reason: 'journal sink failed', fence: 7 }
])
})
})
@@ -58,3 +58,57 @@ describe('StructuredAgentSessionAdapterRouter.closeSession', () => {
expect(dispatch).toHaveBeenCalledTimes(1)
})
})
describe('StructuredAgentSessionAdapterRouter optional lifecycle methods', () => {
it.each([
['forceCloseSession', 'forceCloseSession'],
['disposeSession', 'disposeSession']
] as const)(
'%s forwards to the owner and retains it until proven stopped',
async (_label, method) => {
const claude = adapterOf(vi.fn(async () => true))
const stop = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true)
claude[method] = stop
const dispatch = vi.fn().mockResolvedValue({ state: 'unknown', reason: 'test' })
claude.dispatch = dispatch
const codex = adapterOf(vi.fn(async () => false))
const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {})
const identity = { sessionId: 'session-1', agent: 'claude' } as never
await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' })
const stopSession = router[method]
await expect(stopSession('session-1')).resolves.toBe(false)
await expect(
router.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-1',
body: {} as never,
fence: 1
})
).resolves.toMatchObject({ state: 'unknown' })
await expect(stopSession('session-1')).resolves.toBe(true)
expect(stop).toHaveBeenCalledTimes(2)
expect(dispatch).toHaveBeenCalledOnce()
}
)
it.each(['forceCloseSession', 'disposeSession'] as const)(
'falls back to closeSession when an owner lacks %s',
async (method) => {
const closeSession = vi.fn().mockResolvedValue(true)
const claude = adapterOf(vi.fn(async () => true))
claude.closeSession = closeSession
const codex = adapterOf(vi.fn(async () => false))
const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {})
await router.acquire({
identity: { sessionId: 'session-1', agent: 'claude' } as never,
fence: 1,
spawnToken: 'spawn-1'
})
const stopSession = router[method]
await expect(stopSession('session-1')).resolves.toBe(true)
expect(closeSession).toHaveBeenCalledWith('session-1')
}
)
})
@@ -66,13 +66,28 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
historyFilePath = (input: { identity: AgentSessionJournalIdentity }) =>
this.requireAgent(input.identity).historyFilePath?.(input) ?? Promise.resolve(null)
async closeSession(sessionId: string): Promise<boolean> {
closeSession = (sessionId: string): Promise<boolean> =>
this.stopSession(sessionId, (adapter) => adapter.closeSession)
forceCloseSession = (sessionId: string): Promise<boolean> =>
this.stopSession(sessionId, (adapter) => adapter.forceCloseSession ?? adapter.closeSession)
disposeSession = (sessionId: string): Promise<boolean> =>
this.stopSession(sessionId, (adapter) => adapter.disposeSession ?? adapter.closeSession)
private async stopSession(
sessionId: string,
selectStop: (
adapter: StructuredAgentSessionAdapter
) => NonNullable<StructuredAgentSessionAdapter['closeSession']> | undefined
): Promise<boolean> {
const adapter = this.owners.get(sessionId)
if (!adapter) {
return false
}
const closed = await adapter.closeSession?.(sessionId)
if (closed === true) {
const stop = selectStop(adapter)
const stopped = await stop?.call(adapter, sessionId)
if (stopped === true) {
this.owners.delete(sessionId)
return true
}