mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
fix(native-chat): a Codex Stop that Codex refuses or never answers ends the Codex process (#24334)
* fix(codex): a Stop whose interrupt Codex refused or never answered ends the session A Codex Stop is the interrupt alone, so its background terminals keep running. When Codex refused the interrupt, or never answered it, the turn kept running and the chat said "Codex didn't stop" or "Cancellation was not confirmed." with no way to stop it short of closing the chat. Now, after an interrupt that failed, the Stop re-reads the conversation once Codex's frames already received have landed, and ends the child through the host's usual stop (proof of exit, then the lease release) when it still runs what the Stop was sent for. It skips a conversation at rest, a child whose exit already ended its turn, and one Codex has moved on to a different turn. The turn then reads as the user's cancellation. If the child's exit is not proven, the failure is reported and the Stop keeps its "didn't stop" or "not confirmed" row, which is still true. A Stop Codex took keeps the child, unchanged. * fix(codex): end the child only for an interrupt whose effect is unknown, on the turn the Stop meant - Codex's invalid-request refusal (-32600: no active turn, another turn active, thread not loaded) states the named turn is not running, and can arrive before that turn's end frame. The Codex adapter now marks it `turnNotRunning`, and such a refusal never ends the child. Only an internal-error refusal (-32603), an unanswered interrupt, or a thrown cancel does. - A Stop that named a turn, or an unnamed Stop that read one, ends the child only when the journal, after draining received frames, still shows that same turn. A session working on a follow-up whose turn has not opened is no longer ended by a Stop of the finished turn. - When the child's exit was proven and only a later cleanup step failed, the Stop reads as requested; the failure is still reported. - `AgentSessionCancelOutcome` moves beside the adapter's other Stop members (re-exported), which keeps the adapter file within its line budget. * test(codex): pin that a failed interrupt decides on the drained journal The frames Codex sent before the interrupt failed are held in the sink, so a read that skips the drain sees the stopped turn still running. * test(codex): a refusal naming a turn Codex is not running carries turnNotRunning * test(native-chat): fail the route release synchronously, as the adapter's acknowledgement is * docs(codex): a -32600 Codex could not parse also reads as not running and keeps the child * fix(native-chat): a named Stop whose child end is unproven says Codex didn't stop, not that the turn finished The new branch has just read the turn running, so the named Stop's 'already finished' row was false.
This commit is contained in:
@@ -52,7 +52,9 @@ describe('a Codex Stop that names no turn', () => {
|
||||
detail: {
|
||||
text: 'expected active turn id turn-journal but found turn-1',
|
||||
audience: 'person'
|
||||
}
|
||||
},
|
||||
// An invalid-request refusal: the named turn is not the one Codex is running.
|
||||
turnNotRunning: true
|
||||
}
|
||||
})
|
||||
expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-journal'])
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type CodexStructuredLaunch,
|
||||
type CodexStructuredSessionEvent
|
||||
} from './codex-structured-session-adapter'
|
||||
import type { CodexStructuredSessionAdapterDeps } from './codex-structured-session-state'
|
||||
|
||||
export const THREAD_ID = 'thread-abc'
|
||||
|
||||
@@ -104,7 +105,9 @@ export function answerWithOpenedTurn(
|
||||
export function adapterFor(
|
||||
codex: ReturnType<typeof fakeCodex>,
|
||||
launch: Partial<CodexStructuredLaunch> = {},
|
||||
events: CodexStructuredSessionEvent[] = []
|
||||
events: CodexStructuredSessionEvent[] = [],
|
||||
/** Host wiring the runtime adds, such as the late dispatch settlement. */
|
||||
deps: Partial<CodexStructuredSessionAdapterDeps> = {}
|
||||
): CodexStructuredSessionAdapter {
|
||||
let acquisitionGeneration = 0
|
||||
return new CodexStructuredSessionAdapter({
|
||||
@@ -120,7 +123,8 @@ export function adapterFor(
|
||||
openConnection: codex.openConnection,
|
||||
readProcessStartTime: async () => 1_700_000_000_000,
|
||||
now: () => 1_700_000_000_500,
|
||||
mintAcquisitionGeneration: () => `generation-${++acquisitionGeneration}`
|
||||
mintAcquisitionGeneration: () => `generation-${++acquisitionGeneration}`,
|
||||
...deps
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,21 @@ import type { AgentSessionCancelOutcome } from '../native-chat/agent-session-wir
|
||||
import type { CodexSession } from './codex-structured-session-state'
|
||||
import type { CodexJournalTranslationAdmission } from './codex-structured-journal-contracts'
|
||||
|
||||
/**
|
||||
* Codex's interrupt handler answers -32600 when the named turn is not its running one (no turn,
|
||||
* another turn, thread not loaded) and -32603 when it could not submit the interrupt. A request it
|
||||
* could not parse, or one before initialize, also answers -32600: read as not running, it keeps the
|
||||
* child, as before.
|
||||
*/
|
||||
function isCodexTurnNotRunningRefusal(error: unknown): boolean {
|
||||
return isCodexAppServerRequestError(error) && error.code === -32600
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex answers a turn's interrupt only as that turn ends, so the answer is what confirms the
|
||||
* Stop. The interrupt is the whole Stop: Codex kills the turn's one-shot commands itself and keeps
|
||||
* its background terminals running until the thread ends.
|
||||
* Stop. An answered interrupt is the whole Stop: Codex kills the turn's one-shot commands itself
|
||||
* and keeps its background terminals running until the thread ends. One Codex could not carry out,
|
||||
* or never answered, leaves the host to end the child (`performCancel`).
|
||||
*/
|
||||
export async function interruptCodexTurn(input: {
|
||||
session: CodexSession
|
||||
@@ -29,7 +40,13 @@ export async function interruptCodexTurn(input: {
|
||||
throw error
|
||||
}
|
||||
const detail = providerDiagnosticOf(error)
|
||||
return { cancelled: false, refusal: detail ? { detail } : {} }
|
||||
return {
|
||||
cancelled: false,
|
||||
refusal: {
|
||||
...(detail ? { detail } : {}),
|
||||
...(isCodexTurnNotRunningRefusal(error) ? { turnNotRunning: true } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
const promptAdmission = input.onConfirmed?.()
|
||||
if (promptAdmission && !promptAdmission.accepted) {
|
||||
|
||||
Reference in New Issue
Block a user