mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* refactor(native-chat): keep the provider resume handle opaque to shared code
Shared structured-chat code parsed each provider's resume handle: Claude's
session id and branch leaf, Codex's thread id, through a 'claude' | 'codex'
union every new agent had to widen. The in-memory handle is now
{ transport, agent, nativeId, providerData? }: shared readers use nativeId,
lease and handle-chain checks compare transport and agent, and only the
Claude adapter reads its leaf (providerData).
Stored and wire forms are unchanged for Claude and Codex. One encoding
module writes their typed shapes and decodes both those and the neutral
shape a new transport uses, which an older build refuses as unreadable
rather than reading as Codex. Key and root strings, which fork seeds,
superseded creations and resume offers persist, stay byte-identical.
The journal's own handle type becomes the journal-row and attach-wire
encoding of the same handle, and the journal identity carries the
neutral handle (null before the provider proves one).
No user-visible change.
* fix(native-chat): derive journal-row provider handles from the journal identity
The journal row converter now takes the identity every caller already holds,
so a row's handle has one obvious constructor. Tests that wrote the in-memory
handle straight into journal rows now build it through that converter, and the
processless Claude fixture names a not-yet-proved handle as null.
* fix(native-chat): refuse a stored provider handle written in both forms
A typed Claude or Codex handle that also carries the neutral form's
transport, agent, native id or provider data named two identities; it
was read as Claude or Codex and the next write dropped the other one.
Such a row now stays unreadable and is set aside untouched.
* refactor(native-chat): route structured agents through registered definitions
The structured-chat shared layer named Claude and Codex in a closed router
type, in option reads, in frame classification, and in renderer label and
validation branches, so adding an agent meant editing shared types. Each
agent now has a definition (capabilities, resting option rules) owned by its
own module; the router is a registry of definition + adapter pairs built at
runtime setup, and shared code reads the definition instead of the name.
No behavior change for Claude or Codex; no wire or stored shape change.
* refactor(native-chat): name the structured agent list once in host types
The host types, conversation commands, provider-session ownership and the
runtime's create path spelled the structured agent list inline; they now use
the one shared type the record and wire already validate against.
* fix(native-chat): narrow the record before reading its agent's option rules
* refactor(native-chat): make the router's registrations the only agent definition lookup
The at-rest option reads looked definitions up in a second, separately populated map, so a
registered definition could route and declare capabilities while the static map decided which
options a chat at rest accepted. The router now answers `definition(agent)` from its
registrations, the at-rest readers take it through the host's adapter, and the built-in
definitions are listed only where the runtime composes the router.
* test(native-chat): use opaque handle in queued rejection fixture
* test(native-chat): share one Codex journal identity in the integration suite
Main grew the suite to the 800-line limit; the opaque-handle import pushed it
over. The two tests built the same identity inline.
* refactor(agent-session): name the handle's adapter state resumeCursor
Rename the neutral provider handle's providerData to resumeCursor before any
row persists the neutral form: it is an adapter-owned resume position (Claude's
transcript leaf), never identity. Claude/Codex stored and wire bytes are
unchanged; their typed shapes never carried the field.
State the stored-form contract (a handle's field set is closed; later per-link
data goes on the chain link, which every build preserves) and pin it with a
record round-trip test. Document that transport records the id space the
native id was minted in, which can differ from the agent's current transport.
* refactor(agent-session): one required agent registry; declarations admit what they claim
A StructuredAgentRegistry, built once from the {definition, adapter}
registrations, is now a required host dependency and the router routes with
it. The adapter interface loses its optional router-only capabilities?() and
definition?(); every reader (options at rest, thread goal, rewind, the
/compact handover) asks the registry. A live session still narrows rewind
through the adapter, and the host combines declared and narrowed in one
helper. The registry refuses a registration that declares compact, a thread
goal or rewind without the adapter method behind it.
/compact is admitted by the declared capability, so an agent that declares
compact:false gets the commandRefused fact instead of a thrown error.
Also: the cut-turn notice names the agent from the catalog, create-support
builds the account home through agentSessionAccountHome, and the turn
status text older clients read names the session's agent instead of
defaulting to Codex (Claude/Codex text unchanged).
* refactor(agent-session): the router applies the declared rewind itself
The router holds the registry, so its rewindSupport answers the owner's
declared rewind narrowed by the adapter, in one place no reader can bypass.
The host-side combining helper is gone; readers ask the adapter they hold.
* chore(agent-session): the registry is the one lookup; drop the test-only empty capability record
* test(native-chat): build the Stop-opened-turn test's identity and turn context the current way
The test (#25056) landed before the opaque provider handle (#24991), so main still built
the old {kind, threadId} handle; the turn context also needs this branch's agent registry.
* test(ratchet): require src/main/provider-process now that it has landed
* test(native-chat): keep main's opaque-handle import in the Stop-opened-turn test
Main's #25706 and this branch both added the import at different lines; the merge kept both.
127 lines
6.4 KiB
TypeScript
127 lines
6.4 KiB
TypeScript
// How a durable session record becomes a Codex process launch.
|
|
//
|
|
// Every input is read back from the record the store already made durable, not
|
|
// from the call that triggered the acquire. A client that attaches twice must
|
|
// land in the same working directory under the same account home, and a resume
|
|
// must name the thread this session actually proved — never one a caller asks
|
|
// for, which is how a resume becomes a fork wearing a resume's name.
|
|
|
|
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
|
|
import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle'
|
|
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
|
import { resolveCodexCommand } from '../codex-cli/command'
|
|
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
|
|
import type { CodexStructuredLaunch } from './codex-structured-session-adapter'
|
|
import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy'
|
|
import { resolvePinnedCodexRolloutProof } from './codex-pinned-rollout-proof'
|
|
import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table'
|
|
import { agentConfigDirectoryVariable } from '../../shared/agent-session-account-home'
|
|
|
|
export type CodexStructuredLaunchResolverDeps = {
|
|
store: AgentSessionRecordStore
|
|
/** Absolute path of a workspace on this host. Rejects when the workspace no
|
|
* longer resolves, which is the case a stale mobile client hits. */
|
|
resolveWorkspacePath: (workspaceId: string) => Promise<string>
|
|
/** Overridden in tests; production scans the boot-cached PATH and version-manager dirs. */
|
|
resolveCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string
|
|
/** Fresh shell/configured environment for this spawn; never written to the session record. */
|
|
resolveEnvironment?: () => Promise<NodeJS.ProcessEnv>
|
|
resolveRollout?: typeof resolvePinnedCodexRolloutProof
|
|
/** Test seam for the host capability; production uses the native process table. */
|
|
isWindowsProcessStartTimeAvailable?: () => boolean
|
|
/** The user's Agent Permissions setting as thread policy, re-read per acquisition.
|
|
* States both postures outright — a resume inherits the last one for any field left absent. */
|
|
resolvePermissionPolicy?: () => CodexStructuredPermissionPolicy
|
|
}
|
|
|
|
export type CodexStructuredInvocation = {
|
|
command: string
|
|
environment: NodeJS.ProcessEnv | undefined
|
|
}
|
|
|
|
/**
|
|
* The one place a structured Codex child's binary and environment are
|
|
* resolved. The session launch and the session-less catalog probe both build
|
|
* on it, so a probe can never list under a different binary or env than the
|
|
* session it stands in for. Env VALUES stay out of the catalog fingerprint:
|
|
* drift there heals on the next refresh.
|
|
*/
|
|
export async function resolveCodexStructuredInvocation(
|
|
deps: Pick<CodexStructuredLaunchResolverDeps, 'resolveCommand' | 'resolveEnvironment'>
|
|
): Promise<CodexStructuredInvocation> {
|
|
const environment = await deps.resolveEnvironment?.()
|
|
const pathEnv = environment?.PATH ?? environment?.Path ?? null
|
|
const homePath = environment?.HOME ?? environment?.USERPROFILE
|
|
const command = (deps.resolveCommand ?? resolveCodexCommand)({
|
|
pathEnv,
|
|
...(homePath ? { homePath } : {})
|
|
})
|
|
return { command, environment }
|
|
}
|
|
|
|
export function createCodexStructuredLaunchResolver(
|
|
deps: CodexStructuredLaunchResolverDeps
|
|
): (input: { identity: AgentSessionJournalIdentity }) => Promise<CodexStructuredLaunch> {
|
|
return async ({ identity }) => {
|
|
const record = deps.store.getRecord(identity.sessionId)
|
|
if (!record) {
|
|
throw new Error(`no durable agent-session record for ${identity.sessionId}`)
|
|
}
|
|
const { location, accountHome } = record
|
|
if (record.provider !== 'codex') {
|
|
throw new Error(`session ${identity.sessionId} is a ${record.provider} session`)
|
|
}
|
|
// This adapter spawns a child on the machine the runtime itself runs on.
|
|
// A session pinned elsewhere belongs to that host's runtime, and quietly
|
|
// starting it here would put a second writer on the same thread.
|
|
if (location.executionHostId !== LOCAL_EXECUTION_HOST_ID || location.wslDistro !== null) {
|
|
throw new Error(
|
|
`codex structured sessions run on the local host, not ${location.executionHostId}`
|
|
)
|
|
}
|
|
// Refuse before resolving launch data; a PID alone cannot prove Windows ownership.
|
|
if (
|
|
process.platform === 'win32' &&
|
|
!(deps.isWindowsProcessStartTimeAvailable ?? isWindowsProcessStartTimeAvailable)()
|
|
) {
|
|
throw new Error('codex structured sessions require Windows process creation-time proof')
|
|
}
|
|
const pinned = agentConfigDirectoryVariable('codex')
|
|
if (accountHome.variable !== pinned) {
|
|
throw new Error(`codex sessions pin ${pinned}, not ${accountHome.variable}`)
|
|
}
|
|
const { command, environment } = await resolveCodexStructuredInvocation(deps)
|
|
// `record.launchArgs` is deliberately not read: the configured CLI arguments are a terminal
|
|
// concern, and the permission posture they used to smuggle in is derived per acquisition.
|
|
const permissionPolicy = deps.resolvePermissionPolicy?.()
|
|
const head = agentSessionProviderHandleChainHead(record.providerHandleChain)
|
|
// A Codex record's chain holds only Codex handles; the record store refuses anything else.
|
|
const resumeThreadId = head?.handle.nativeId ?? null
|
|
// The same saved options every turn sends, so the thread and its turns name one model.
|
|
const model = record.options?.model
|
|
return {
|
|
command,
|
|
args: ['app-server'],
|
|
cwd: await deps.resolveWorkspacePath(location.workspaceId),
|
|
codexHome: accountHome.path,
|
|
...(environment ? { env: { ...environment } as Record<string, string> } : {}),
|
|
// An empty chain is a session that has never proved a thread, so it
|
|
// starts one; anything else resumes the last link this session proved.
|
|
resumeThreadId,
|
|
// Only a thread this session created may still be one Codex never saved: a resumed,
|
|
// forked or adopted head names a conversation Codex held.
|
|
...(resumeThreadId && head?.origin === 'created' ? { supersedeIfUnsaved: true } : {}),
|
|
...(permissionPolicy ? { permissionPolicy } : {}),
|
|
...(model ? { model } : {}),
|
|
...(resumeThreadId
|
|
? {
|
|
resumePath: await (deps.resolveRollout ?? resolvePinnedCodexRolloutProof)(
|
|
accountHome.path,
|
|
resumeThreadId
|
|
)
|
|
}
|
|
: {})
|
|
}
|
|
}
|
|
}
|