mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +00:00
Close remaining structured mobile RC blockers
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user