Close remaining structured mobile RC blockers

This commit is contained in:
Brennan Benson
2026-08-09 21:35:24 -07:00
parent ec917184c3
commit dcd26ef677
23 changed files with 452 additions and 75 deletions
@@ -319,6 +319,34 @@ describe('CodexStructuredSessionAdapter.acquire', () => {
await closing
expect(codex.connections[0]?.closeCount).toBe(1)
})
it('fences an acquisition while launch resolution is still pending', async () => {
const launch = Promise.withResolvers<CodexStructuredLaunch>()
const codex = fakeCodex()
const adapter = new CodexStructuredSessionAdapter({
resolveLaunch: () => launch.promise,
openConnection: codex.openConnection,
readProcessStartTime: async () => 1_700_000_000_000
})
const acquiring = adapter.acquire({
identity: identityFor('session-1'),
fence: 7,
spawnToken: 'spawn-9'
})
const closing = adapter.closeAll()
launch.resolve({
command: 'codex',
args: ['app-server'],
cwd: '/work/repo',
codexHome: null,
resumeThreadId: null
})
await expect(acquiring).rejects.toThrow('superseded while being acquired')
await closing
expect(codex.connections).toHaveLength(0)
})
})
describe('CodexStructuredSessionAdapter.dispatch', () => {
@@ -768,10 +796,11 @@ describe('CodexStructuredSessionAdapter lifecycle', () => {
const events: CodexStructuredSessionEvent[] = []
const adapter = await acquired(codex, {}, events)
await adapter.acquire({ identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10' })
const endedBeforeStaleExit = events.filter((event) => event.type === 'ended').length
codex.connections[0].handlers.onExit?.(new Error('the superseded child died'))
expect(events.some((event) => event.type === 'ended')).toBe(false)
expect(events.filter((event) => event.type === 'ended')).toHaveLength(endedBeforeStaleExit)
expect(await adapter.historyFilePath({ identity: identityFor('session-1') })).toBe(
'/rollouts/abc.jsonl'
)
@@ -796,13 +825,14 @@ describe('CodexStructuredSessionAdapter lifecycle', () => {
it('flushes the final coalesced text before a graceful close', async () => {
const codex = fakeCodex()
const bodies: AgentJournalMessageItem[] = []
const tombstones: unknown[] = []
const sink: StructuredAgentSessionEventSink = {
appendItem: (_identity, body) => {
if (body.kind === 'message') {
bodies.push(body)
}
},
appendTombstone: () => {},
appendTombstone: (identity) => tombstones.push(identity),
publish: () => {}
}
const adapter = adapterFor(codex)
@@ -827,5 +857,11 @@ describe('CodexStructuredSessionAdapter lifecycle', () => {
await adapter.closeSession('session-1')
expect(bodies.at(-1)?.blocks).toEqual([{ type: 'text', text: 'last words' }])
expect(tombstones).toContainEqual({
provider: 'legacy',
agent: 'codex',
sessionId: 'session-1',
recordId: 'turn-lifecycle:turn-1'
})
})
})
@@ -25,7 +25,9 @@ import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-fa
import { openCodexThread } from './codex-structured-thread-open'
import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start'
import { supportsCodexStructuredLocation } from './codex-structured-location-support'
import { closeCodexPublishedSession } from './codex-structured-session-close'
import {
cancelCodexAcquisitionAttempt,
createCodexAcquisitionAttempt,
type CodexAcquisitionAttempt,
type CodexSession,
@@ -39,11 +41,7 @@ export type {
CodexStructuredSessionEvent
} from './codex-structured-session-state'
// The Codex half of the structured agent-session wire: one long-lived
// `codex app-server` child per session, started or resumed under the lease the
// host already reserved. Everything durable — journal, fence, idempotency —
// belongs to the wire; this adapter only starts the process, names the turn the
// provider accepted, and answers Codex's blocking prompt requests.
// Owns one app-server child per structured Codex session; durable state stays on the wire.
export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdapter {
private readonly sessions = new Map<string, CodexSession>()
@@ -53,26 +51,19 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
supportsLocation = supportsCodexStructuredLocation
private get requestTimeoutMs(): number | undefined {
return this.deps.requestTimeoutMs
}
async acquire(input: {
identity: AgentSessionJournalIdentity
fence: number
spawnToken: string
events?: StructuredAgentSessionEventSink
}): Promise<AgentSessionAcquisition> {
// A re-acquire at a new fence must not leave the old child writing.
await this.closeSession(input.identity.sessionId)
const sessionId = input.identity.sessionId
const launch = await this.deps.resolveLaunch({ identity: input.identity })
const previousAttempt = this.acquiring.get(sessionId)
const attempt = createCodexAcquisitionAttempt()
const acquisition = attempt.window
this.acquiring.set(sessionId, attempt)
let primaryThreadId =
input.identity.providerHandle.kind === 'codex' ? input.identity.providerHandle.threadId : null
// Registered before the spawn, because the handshake itself can emit.
this.acquiring.set(sessionId, attempt)
const translator = input.events
? createCodexJournalTranslator({
sink: input.events,
@@ -84,6 +75,15 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
const open = this.deps.openConnection ?? openCodexAppServerConnection
try {
await cancelCodexAcquisitionAttempt(previousAttempt)
await this.closePublishedSession(sessionId)
if (attempt.cancelled || this.acquiring.get(sessionId) !== attempt) {
throw new Error(`codex session ${sessionId} was superseded while being acquired`)
}
const launch = await this.deps.resolveLaunch({ identity: input.identity })
if (attempt.cancelled || this.acquiring.get(sessionId) !== attempt) {
throw new Error(`codex session ${sessionId} was superseded while being acquired`)
}
const connection = await open(
{
command: launch.command,
@@ -106,7 +106,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
}
)
acquisition.connection = connection
const opened = await openCodexThread(connection, launch, this.requestTimeoutMs)
const opened = await openCodexThread(connection, launch, this.deps.requestTimeoutMs)
primaryThreadId = opened.threadId
const acquired: AgentSessionAcquisition = {
process: await codexProcessIdentity(
@@ -121,10 +121,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
observedAt: this.deps.now?.() ?? Date.now()
})
}
// Publication is one step at the end: everything the caller is promised is
// in hand, the child is still alive, and this attempt still owns the
// session. A half-published session would be a live map entry in front of
// a child nobody can reach.
// Publish only after every promised identity is proven and this attempt still owns the child.
if (connection.closed) {
throw new Error(`codex app-server for session ${sessionId} exited while being acquired`)
}
@@ -161,8 +158,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
}
}
/** Buffers an event that arrived before the session was published, and drops
* one from a connection this session has already replaced. */
/** Buffers pre-publication events and drops events from superseded children. */
private deliver(
acquisition: CodexAcquisitionAttempt['window'],
sessionId: string,
@@ -176,8 +172,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
}
}
/** Only the connection that currently owns the session may retire it, or a
* superseded child's death would evict its live replacement. */
/** Only the current connection may retire a session. */
private handleExit(
sessionId: string,
acquisition: CodexAcquisitionAttempt['window'],
@@ -210,8 +205,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
this.emit(session, { type: 'notification', sessionId, threadId, method, params })
}
/** Journal first, observers second: a test tap must never see a row the
* journal has not been told about. */
/** Journal first so observers never see an event ahead of its durable row. */
private emit(session: CodexSession, event: CodexStructuredSessionEvent): void {
session.translator?.handle(event)
this.deps.onEvent?.(event)
@@ -250,7 +244,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
body: AgentJournalMessageItem
fence: number
}): Promise<AgentSessionDispatchOutcome> {
return dispatchCodexTurn(this.session(input.sessionId), input, this.requestTimeoutMs)
return dispatchCodexTurn(this.session(input.sessionId), input, this.deps.requestTimeoutMs)
}
async cancelTurn(input: {
@@ -263,7 +257,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
await session.connection.request(
'turn/interrupt',
{ threadId: session.threadId, turnId: input.turnId },
{ timeoutMs: this.requestTimeoutMs }
{ timeoutMs: this.deps.requestTimeoutMs }
)
return { cancelled: true }
} catch (error) {
@@ -315,15 +309,15 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
await attempt.window.connection?.close()
await attempt.finished
}
const session = this.sessions.get(sessionId)
if (!session) {
return
}
this.sessions.delete(sessionId)
session.prompts.clear()
session.translator?.flush()
session.translator?.dispose()
await session.connection.close()
await this.closePublishedSession(sessionId)
}
private async closePublishedSession(sessionId: string): Promise<void> {
await closeCodexPublishedSession({
sessions: this.sessions,
sessionId,
onEvent: this.deps.onEvent
})
}
async closeAll(): Promise<void> {
@@ -0,0 +1,24 @@
import type { CodexSession, CodexStructuredSessionEvent } from './codex-structured-session-state'
export async function closeCodexPublishedSession(input: {
sessions: Map<string, CodexSession>
sessionId: string
onEvent?: (event: CodexStructuredSessionEvent) => void
}): Promise<void> {
const session = input.sessions.get(input.sessionId)
if (!session) {
return
}
input.sessions.delete(input.sessionId)
session.prompts.clear()
const event: CodexStructuredSessionEvent = {
type: 'ended',
sessionId: input.sessionId,
reason: 'codex session closed'
}
session.translator?.handle(event)
input.onEvent?.(event)
session.translator?.flush()
session.translator?.dispose()
await session.connection.close()
}
@@ -63,3 +63,14 @@ export function createCodexAcquisitionAttempt(): CodexAcquisitionAttempt {
})
return { window: new CodexAcquisitionWindow(), cancelled: false, finished, finish }
}
export async function cancelCodexAcquisitionAttempt(
attempt: CodexAcquisitionAttempt | undefined
): Promise<void> {
if (!attempt) {
return
}
attempt.cancelled = true
await attempt.window.connection?.close()
await attempt.finished
}