mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 16:02:45 +00:00
* feat(native-chat): report Codex background tasks in the chat strip The background-tasks strip works for Claude only; a structured Codex session shows nothing in it. Feed it from the Codex app-server stream. The strip stands for work that OUTLIVED a turn, which is what the monitoring header, Claude's foreground suppression, and the conversation command gate all already assume. Codex has no `is_backgrounded` flag, so that fact is derived from the turn boundary: a `subAgentActivity` child or a primary-thread `commandExecution` becomes visible once the turn it belongs to completes and it is still unsettled. `turn/completed` only reveals a task here, never settles one — measured on `codex app-server` 0.153.4, a spawn_agent child reported `completed` 95.8s after its parent turn ended. Only a child's own activity kind settles it. Codex exposes no honest stop: `turn/interrupt` on a child ends its turn without emitting a terminal activity item and leaves its shell running. So the state carries a new optional `supportsStopAll: false`, the strip hides a control that could not act, and the blocked-command message asks the user to wait rather than to press a button that does not exist. * refactor(codex): move session teardown out of the structured adapter Merging main crossed the 300-line cap on `codex-structured-session-adapter.ts`: the rewind backend (#19235) and this branch's close-time strip clear both landed in it. The four close paths move verbatim into `codex-structured-session-teardown.ts`, where they funnel through one `settled` helper instead of repeating the notification-retry and background-task cleanup at each call site. No ratchet bump. Also normalize a background task's description once at receipt rather than on every projection; the roster is re-projected on each observed frame. * fix(codex): drop the shell row the journal already settles A `commandExecution` still `inProgress` when its turn ends was reported as a `command` task. But `settleCodexJournalTurn` writes exactly those items to the journal as `state: 'failed'` on `turn/completed` and forgets them, so the strip row would have claimed a shell was still running at the same instant Orca recorded that it was not — two surfaces contradicting each other about the same process. A subagent is the opposite case and stays: the roster pointedly does not sweep at a turn boundary, because children measurably outlive it. That leaves the producer making exactly one claim — these spawn_agent children are still live after their turn — which the durable roster row corroborates. * fix(native-chat): track Codex background execution lifetimes * fix(native-chat): keep running tool groups from claiming completion * Fix runtime catalog and capability expectation * fix(codex): keep a child's name on the command row that outlives it A child agent's commands stay hidden behind its agent row while the child works. Once the child's turn settles with a command still running, that command surfaces as its own row labelled from the raw command string, so 'long_probe' became "/bin/zsh -lc 'ping -c 300 127.0.0.1 > /dev/null'" at the moment that row was the only remaining signal for the work. Qualify a child's command row with the child's label. Resolved on read, so a label registered after the command still lands, and bounded by the existing description cap so admission accounting stays valid. Primary- thread commands are left unqualified: they have no child to name. --------- Co-authored-by: Merge Sim <sim@local>
242 lines
8.7 KiB
TypeScript
242 lines
8.7 KiB
TypeScript
import {
|
|
AgentSessionAcquisitionRefusal,
|
|
AgentSessionPreSpawnError,
|
|
type AgentSessionAcquisition,
|
|
type StructuredAgentSessionAcquireInput
|
|
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
|
import {
|
|
closeFailedCodexAcquisition,
|
|
stopSupersededCodexAcquisition
|
|
} from './codex-structured-acquisition-lifecycle'
|
|
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
|
|
import { CodexSubagentExecutions } from './codex-subagent-executions'
|
|
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
|
|
import { openCodexAppServerConnection } from './codex-app-server-connection'
|
|
import { codexProcessIdentity, codexProviderHandleLink } from './codex-structured-owner-identity'
|
|
import { buildCodexStructuredChildEnvironment } from './codex-structured-child-environment'
|
|
import { openCodexThread } from './codex-structured-thread-open'
|
|
import {
|
|
closeCodexPublishedSession,
|
|
handleCodexSessionExit
|
|
} from './codex-structured-session-close'
|
|
import {
|
|
reportedCodexThreadOptions,
|
|
restoredCodexSessionOptions
|
|
} from './codex-structured-session-options'
|
|
import {
|
|
codexSessionLifecycle,
|
|
mintCodexAcquisitionGeneration,
|
|
type CodexAcquisitionRegistry,
|
|
type CodexAcquisitionAttempt,
|
|
type CodexSession,
|
|
type CodexStructuredSessionAdapterDeps
|
|
} from './codex-structured-session-state'
|
|
import type { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation'
|
|
import type { CodexStructuredNotificationRetry } from './codex-structured-notification-retry'
|
|
import type { deliverCodexServerRequest } from './codex-structured-provider-events'
|
|
|
|
export async function acquireCodexStructuredSession(input: {
|
|
input: StructuredAgentSessionAcquireInput
|
|
deps: CodexStructuredSessionAdapterDeps
|
|
sessions: Map<string, CodexSession>
|
|
acquisitions: CodexAcquisitionRegistry
|
|
turnCancellation: CodexStructuredTurnCancellation
|
|
notificationRetries: CodexStructuredNotificationRetry
|
|
deliver: (
|
|
acquisition: CodexAcquisitionAttempt['window'],
|
|
sessionId: string,
|
|
event: () => unknown,
|
|
retainedBytes?: number
|
|
) => void
|
|
handleServerRequest: (
|
|
sessionId: string,
|
|
request: Parameters<typeof deliverCodexServerRequest>[2]
|
|
) => void
|
|
handleUnhandledFrame: (sessionId: string, kind: string, payload: unknown) => void
|
|
forceCloseUnexpected: (
|
|
sessionId: string,
|
|
fence: number,
|
|
acquisitionGeneration: string,
|
|
reason: Error
|
|
) => Promise<boolean>
|
|
}): Promise<AgentSessionAcquisition> {
|
|
const {
|
|
input: acquireInput,
|
|
deps,
|
|
sessions,
|
|
acquisitions,
|
|
turnCancellation,
|
|
notificationRetries
|
|
} = input
|
|
const sessionId = acquireInput.identity.sessionId
|
|
const { previousAttempt, attempt } = acquisitions.start(sessionId)
|
|
const acquisition = attempt.window
|
|
let unbindReadingControl: (() => void) | undefined
|
|
let primaryThreadId =
|
|
acquireInput.identity.providerHandle.kind === 'codex'
|
|
? acquireInput.identity.providerHandle.threadId
|
|
: null
|
|
const subagentExecutions = new CodexSubagentExecutions()
|
|
const translator = acquireInput.events
|
|
? createCodexJournalTranslator({
|
|
sink: acquireInput.events,
|
|
primaryThreadId: () => primaryThreadId,
|
|
subagentExecutions,
|
|
bindPromptItemId: (journalItemId, threadId, promptKey) =>
|
|
acquisition.prompts.bindJournalItemId(journalItemId, threadId, promptKey)
|
|
})
|
|
: null
|
|
const open = deps.openConnection ?? openCodexAppServerConnection
|
|
try {
|
|
await stopSupersededCodexAcquisition({
|
|
sessionId,
|
|
registry: acquisitions,
|
|
replacement: attempt,
|
|
previous: previousAttempt
|
|
})
|
|
acquisitions.assertCurrent(sessionId, attempt)
|
|
if (!(await closeCodexPublishedSession(sessions, sessionId, deps.onEvent))) {
|
|
throw new Error(`codex app-server for session ${sessionId} could not be stopped`)
|
|
}
|
|
acquisitions.assertCurrent(sessionId, attempt)
|
|
const launch = await deps
|
|
.resolveLaunch({ identity: acquireInput.identity })
|
|
.catch((error: unknown) => {
|
|
throw new AgentSessionPreSpawnError(error)
|
|
})
|
|
acquisitions.assertCurrent(sessionId, attempt)
|
|
const connection = await open(
|
|
{
|
|
command: launch.command,
|
|
args: launch.args,
|
|
cwd: launch.cwd,
|
|
env: buildCodexStructuredChildEnvironment(launch, acquireInput.spawnToken, sessionId)
|
|
},
|
|
{
|
|
onNotification: (method, params) =>
|
|
input.deliver(
|
|
acquisition,
|
|
sessionId,
|
|
() => notificationRetries.handle(sessionId, method, params),
|
|
Buffer.byteLength(JSON.stringify(params ?? null), 'utf8')
|
|
),
|
|
onServerRequest: (request) =>
|
|
input.deliver(
|
|
acquisition,
|
|
sessionId,
|
|
() => input.handleServerRequest(sessionId, request),
|
|
Buffer.byteLength(JSON.stringify(request), 'utf8')
|
|
),
|
|
onUnhandledFrame: (kind, payload) =>
|
|
input.deliver(
|
|
acquisition,
|
|
sessionId,
|
|
() => input.handleUnhandledFrame(sessionId, kind, payload),
|
|
Buffer.byteLength(JSON.stringify(payload ?? null), 'utf8')
|
|
),
|
|
onExit: (error) => {
|
|
try {
|
|
handleCodexSessionExit({
|
|
sessions,
|
|
sessionId,
|
|
connection: acquisition.connection,
|
|
error,
|
|
prompts: acquisition.prompts,
|
|
onBackgroundTasksChanged: deps.onBackgroundTasksChanged,
|
|
...(deps.onEvent ? { onEvent: deps.onEvent } : {})
|
|
})
|
|
} finally {
|
|
notificationRetries.clear(sessionId, acquisition.connection)
|
|
}
|
|
}
|
|
}
|
|
)
|
|
acquisition.connection = connection
|
|
if (connection.pauseReading && connection.resumeReading) {
|
|
unbindReadingControl = acquireInput.events?.bindReadingControl?.({
|
|
pauseReading: connection.pauseReading,
|
|
resumeReading: () => {
|
|
connection.resumeReading?.()
|
|
notificationRetries.retry(sessionId, connection)
|
|
}
|
|
})
|
|
}
|
|
acquisitions.assertCurrent(sessionId, attempt)
|
|
const opened = await openCodexThread(connection, launch, deps.requestTimeoutMs)
|
|
acquisitions.assertCurrent(sessionId, attempt)
|
|
primaryThreadId = opened.threadId
|
|
const restoreAdmission = translator?.restoreThread(opened.threadId, opened.thread ?? {})
|
|
if (restoreAdmission && !restoreAdmission.accepted) {
|
|
throw new AgentSessionAcquisitionRefusal(
|
|
'Codex thread history exceeds the bounded restore queue; history was not partially imported.'
|
|
)
|
|
}
|
|
const process = await codexProcessIdentity(
|
|
{ ...acquireInput, pid: connection.pid },
|
|
deps.readProcessStartTime
|
|
)
|
|
acquisitions.assertCurrent(sessionId, attempt)
|
|
const acquired: AgentSessionAcquisition = {
|
|
process,
|
|
link: codexProviderHandleLink({
|
|
threadId: opened.threadId,
|
|
resumed: launch.resumeThreadId !== null,
|
|
fence: acquireInput.fence,
|
|
linkId: deps.mintLinkId?.(),
|
|
observedAt: deps.now?.() ?? Date.now()
|
|
}),
|
|
acquisitionGeneration: mintCodexAcquisitionGeneration(deps)
|
|
}
|
|
if (connection.closed) {
|
|
throw new Error(`codex app-server for session ${sessionId} exited while being acquired`)
|
|
}
|
|
acquisitions.assertCurrent(sessionId, attempt)
|
|
acquisitions.deleteIfCurrent(sessionId, attempt)
|
|
const session: CodexSession = {
|
|
connection,
|
|
...codexSessionLifecycle(acquireInput.fence, acquired.acquisitionGeneration as string),
|
|
threadId: opened.threadId,
|
|
historyPath: opened.historyPath,
|
|
historyMode: opened.historyMode,
|
|
activeTurnIds: new Set(),
|
|
prompts: acquisition.prompts,
|
|
options: restoredCodexSessionOptions(acquireInput.options),
|
|
reportedOptions: reportedCodexThreadOptions(opened),
|
|
turnIdWaiters: [],
|
|
translator,
|
|
backgroundTasks: new CodexBackgroundTaskTracker(opened.threadId, subagentExecutions),
|
|
forceCloseUnexpected: (reason) =>
|
|
input.forceCloseUnexpected(
|
|
sessionId,
|
|
acquireInput.fence,
|
|
acquired.acquisitionGeneration as string,
|
|
reason
|
|
),
|
|
...(unbindReadingControl ? { unbindReadingControl } : {})
|
|
}
|
|
turnCancellation.register(session)
|
|
sessions.set(sessionId, session)
|
|
for (const event of acquisition.drain()) {
|
|
event()
|
|
}
|
|
return acquired
|
|
} catch (error) {
|
|
if (sessions.get(sessionId)?.connection !== acquisition.connection) {
|
|
return closeFailedCodexAcquisition({
|
|
sessionId,
|
|
registry: acquisitions,
|
|
attempt,
|
|
cause: error,
|
|
dispose: () => {
|
|
unbindReadingControl?.()
|
|
translator?.dispose()
|
|
}
|
|
})
|
|
}
|
|
acquisitions.deleteIfCurrent(sessionId, attempt)
|
|
throw error
|
|
} finally {
|
|
attempt.finish()
|
|
}
|
|
}
|