mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
* fix(native-chat): keep saved options on structured route
* fix(native-chat): seed structured session options
* fix(native-chat): preserve create wire compatibility
* fix(native-chat): keep create replayable across an option change
Seeding host-resolved options into the attach fingerprint put a mutable
value into the durable operation identity. A create whose outcome was
unknown, retried under the same operation id after the user reselected a
model, re-resolved different options and hashed to a different
fingerprint — so the ledger refused it as a conflict instead of
replaying. That refusal is not definitive, so no legacy fallback fires
and the launch has no recovery.
Options are the session's initial state, not its identity, and the
reservation still carries them to the record. Excluding them also makes
the digest byte-identical to the pre-change one in every case, not just
when no options resolve.
* refactor(native-chat): name the structured launch option seed
The create-intent resolver narrowed saved options to model/effort with an
inline key literal, inside a file carrying @ts-nocheck — so neither the
key list nor the string narrowing had a typechecked or testable home, and
the repo already expresses this concept as a named shared shape.
Move it to resolveStructuredLaunchSeedOptions beside the persisted
settings it reads, where it is typechecked and unit-tested, and document
why the seed is exactly model and effort: they are the only ids the
picker persists that both providers also accept as strings.
No behavior change. Adds coverage for a non-string persisted effort,
which settings.json can hold and the durable record must not carry.
* test(native-chat): name the structured routing pin for what it asserts
The case drives a saved Codex model and effort through the launch path,
but the structured create intent it asserts on carries no options, so it
pins the route and not the preservation its name claimed. Preservation is
pinned host-side, where the seeding actually happens.
* test(native-chat): pin the empty seed the record cannot carry
valuesByModel is merged over the resolved model, so a stored `model` key
can blank it — the seed then empties out and must resolve to undefined.
Nothing covered that branch, so returning the empty map unguarded stayed
green.
It matters because emitting `{ model: '' }` fails the record's
bounded-string guard, and agent_session_options_invalid is not a wire
refusal code: classifyStoreFailure rethrows it, the client reads the raw
error as an unknown outcome, and the launch strands with no fallback.
Corrects eb33ee6bb3, whose message claimed no behavior change. That
extraction also stopped emitting empty and whitespace-only values, which
is a fix on six input shapes, not a pure move.
---------
Co-authored-by: Merge Sim <sim@local>
366 lines
14 KiB
TypeScript
366 lines
14 KiB
TypeScript
/**
|
|
* Durable agent-session record and its single-writer lease.
|
|
*
|
|
* The record is the session's identity — where it runs, which provider it talks to, which account
|
|
* home is pinned to it — and is independent of any terminal tab. The lease is the separate
|
|
* question of which process is currently allowed to write to it.
|
|
*/
|
|
|
|
import type { ExecutionHostId } from './execution-host'
|
|
import {
|
|
isAgentSessionProviderHandleChain,
|
|
type AgentSessionHandleProvider,
|
|
type AgentSessionProviderHandleLink
|
|
} from './agent-session-provider-handle'
|
|
|
|
export const AGENT_SESSION_RECORD_SCHEMA_VERSION = 2 as const
|
|
|
|
export type AgentSessionWorkspaceKind = 'git-worktree' | 'folder'
|
|
|
|
/**
|
|
* Where the provider process actually runs. WSL is called out separately from the execution host
|
|
* id because a WSL workspace is served by the local host but is a distinct filesystem, account
|
|
* root, and process namespace — two sessions there must never collide with their native twins.
|
|
*/
|
|
export type AgentSessionExecutionLocation = {
|
|
executionHostId: ExecutionHostId
|
|
/** Distro name when the provider runs inside WSL; null for native and remote hosts. */
|
|
wslDistro: string | null
|
|
workspaceId: string
|
|
workspaceKind: AgentSessionWorkspaceKind
|
|
}
|
|
|
|
/** Account root pinned at launch by the account selector, so a resume cannot drift to another login. */
|
|
export type AgentSessionAccountHome = {
|
|
variable: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME'
|
|
/** Host-resolved absolute path in the execution host's own path syntax. */
|
|
path: string
|
|
}
|
|
|
|
/** Provider launch environment captured by the host when the session is created. */
|
|
export type AgentSessionLaunchEnv = Record<string, string>
|
|
|
|
/** Provider CLI arguments captured by the host when the session is created. */
|
|
export type AgentSessionLaunchArgs = string[]
|
|
|
|
export type AgentSessionOwnerRuntimeKind = 'native' | 'tui'
|
|
|
|
export type AgentSessionHandoffStage =
|
|
| 'preparing'
|
|
| 'old-owner-stopped'
|
|
| 'new-owner-proving'
|
|
| 'recovering'
|
|
| 'manual-recovery'
|
|
|
|
/**
|
|
* PID-reuse-safe process identity. `spawnToken` is the only element available on every platform:
|
|
* process start time costs a CIM query on Windows and is absent in some containers. An exact
|
|
* identity stays in `recovering`; an ownerless, unattributable reservation uses `manual-recovery`.
|
|
*/
|
|
export type AgentSessionProcessIdentity = {
|
|
hostId: string
|
|
pid: number
|
|
processStartTimeMs: number | null
|
|
spawnToken: string
|
|
}
|
|
|
|
export type AgentSessionJournalCheckpoint = { epoch: number; sequence: number }
|
|
|
|
/**
|
|
* Mirrors the in-memory claim registry's reserved / live / conflicted states so a conflict
|
|
* survives a restart. `released` has no registry equivalent: the registry expresses "no owner" by
|
|
* deleting the entry, and a durable record that outlives its owner needs a name for that.
|
|
*/
|
|
export type AgentSessionClaimStatus = 'reserved' | 'live' | 'conflicted' | 'released'
|
|
|
|
export type AgentSessionDeathEvidence = {
|
|
kind: 'exit-observed' | 'pid-absent' | 'identity-mismatch'
|
|
detail: string
|
|
observedAt: number
|
|
}
|
|
|
|
export type AgentSessionLease = {
|
|
sessionId: string
|
|
runtimeKind: AgentSessionOwnerRuntimeKind
|
|
/** Durable monotonic integer; only acquisition CAS and proven eviction move it. */
|
|
runtimeFence: number
|
|
handoffStage: AgentSessionHandoffStage | null
|
|
/** Link id of the provider handle this owner proved; the full chain lives on the record. */
|
|
provenHandleLinkId: string | null
|
|
/** Null between the durable reservation and the observed spawn. */
|
|
ownerProcess: AgentSessionProcessIdentity | null
|
|
/** Reserved before any process exists, then matched against the child's environment. */
|
|
reservedSpawnToken: string | null
|
|
/** Set only when acquisition failed before any spawn attempt. */
|
|
processlessAt?: number | null
|
|
leaseDeadlineAt: number
|
|
lastRenewedAt: number
|
|
handoffOperationId: string | null
|
|
journalCheckpoint: AgentSessionJournalCheckpoint | null
|
|
/** Key id that minted the HMAC claim this lease was granted under. */
|
|
claimKeyId: string
|
|
claimStatus: AgentSessionClaimStatus
|
|
/** True from load until the host adjudicates it; no writer is granted while set. */
|
|
unreconciled: boolean
|
|
/**
|
|
* Lowest fence a future grant may use. Set only after the store recovers from its backup, where
|
|
* the commit that never landed may already have granted a fence the backup cannot show. The
|
|
* CURRENT fence is deliberately left alone: `live` means a handle proven at exactly that number,
|
|
* so rewriting it would invalidate the record it is trying to save.
|
|
*/
|
|
minimumNextFence?: number
|
|
deathEvidence: AgentSessionDeathEvidence | null
|
|
/** A positively observed provider exit whose terminal journal settlement still needs retry. */
|
|
settlementRetryRequired?: boolean
|
|
/** Stable lifecycle batch id used when retrying the terminal settlement. */
|
|
settlementRetryId?: string
|
|
}
|
|
|
|
export type AgentSessionRecord = {
|
|
schemaVersion: typeof AGENT_SESSION_RECORD_SCHEMA_VERSION
|
|
sessionId: string
|
|
location: AgentSessionExecutionLocation
|
|
provider: AgentSessionHandleProvider
|
|
providerHandleChain: AgentSessionProviderHandleLink[]
|
|
accountHome: AgentSessionAccountHome
|
|
/** Provider options acknowledged for the next turn, restored across owner replacement. */
|
|
options?: Record<string, string>
|
|
launchArgs?: AgentSessionLaunchArgs
|
|
lease: AgentSessionLease
|
|
createdAt: number
|
|
updatedAt: number
|
|
}
|
|
|
|
export type AgentSessionOptionsReplacement = {
|
|
sessionId: string
|
|
fence: number
|
|
options: Readonly<Record<string, string>>
|
|
now: number
|
|
}
|
|
|
|
const MAX_ID_LENGTH = 512
|
|
const MAX_PATH_LENGTH = 4096
|
|
const MAX_LAUNCH_ENV_ENTRIES = 256
|
|
const MAX_LAUNCH_ENV_VALUE_LENGTH = 65_536
|
|
const MAX_LAUNCH_ARGS = 256
|
|
const MAX_LAUNCH_ARGS_BYTES = 16 * 1024
|
|
const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/
|
|
|
|
function isBoundedString(value: unknown, max: number): value is string {
|
|
return typeof value === 'string' && value.length > 0 && value.length <= max
|
|
}
|
|
|
|
export function isAgentSessionId(value: unknown): value is string {
|
|
return typeof value === 'string' && SESSION_ID_PATTERN.test(value)
|
|
}
|
|
|
|
/** NUL cannot occur in a host id, distro name, or workspace id, so no component can forge a join. */
|
|
const SCOPE_KEY_SEPARATOR = '\u0000'
|
|
|
|
/**
|
|
* Scope key for host-and-workspace isolation. Native, WSL, and SSH copies of one workspace id are
|
|
* different sessions; collapsing them would let one host adjudicate another host's lease.
|
|
*/
|
|
export function agentSessionScopeKey(location: AgentSessionExecutionLocation): string {
|
|
return [location.executionHostId, location.wslDistro ?? '', location.workspaceId].join(
|
|
SCOPE_KEY_SEPARATOR
|
|
)
|
|
}
|
|
|
|
export function agentSessionExecutionLocationsEqual(
|
|
left: AgentSessionExecutionLocation,
|
|
right: AgentSessionExecutionLocation
|
|
): boolean {
|
|
return (
|
|
agentSessionScopeKey(left) === agentSessionScopeKey(right) &&
|
|
left.workspaceKind === right.workspaceKind
|
|
)
|
|
}
|
|
|
|
export function isAgentSessionExecutionLocation(
|
|
value: unknown
|
|
): value is AgentSessionExecutionLocation {
|
|
if (typeof value !== 'object' || value === null) {
|
|
return false
|
|
}
|
|
const location = value as Partial<AgentSessionExecutionLocation>
|
|
return (
|
|
isBoundedString(location.executionHostId, MAX_ID_LENGTH) &&
|
|
(location.wslDistro === null || isBoundedString(location.wslDistro, MAX_ID_LENGTH)) &&
|
|
isBoundedString(location.workspaceId, MAX_ID_LENGTH) &&
|
|
(location.workspaceKind === 'git-worktree' || location.workspaceKind === 'folder')
|
|
)
|
|
}
|
|
|
|
export function isAgentSessionProcessIdentity(
|
|
value: unknown
|
|
): value is AgentSessionProcessIdentity {
|
|
if (typeof value !== 'object' || value === null) {
|
|
return false
|
|
}
|
|
const identity = value as Partial<AgentSessionProcessIdentity>
|
|
return (
|
|
isBoundedString(identity.hostId, MAX_ID_LENGTH) &&
|
|
Number.isSafeInteger(identity.pid) &&
|
|
(identity.pid as number) > 0 &&
|
|
(identity.processStartTimeMs === null ||
|
|
(Number.isSafeInteger(identity.processStartTimeMs) &&
|
|
(identity.processStartTimeMs as number) >= 0)) &&
|
|
isBoundedString(identity.spawnToken, MAX_ID_LENGTH)
|
|
)
|
|
}
|
|
|
|
function isAgentSessionAccountHome(value: unknown): value is AgentSessionAccountHome {
|
|
if (typeof value !== 'object' || value === null) {
|
|
return false
|
|
}
|
|
const home = value as Partial<AgentSessionAccountHome>
|
|
return (
|
|
(home.variable === 'CLAUDE_CONFIG_DIR' || home.variable === 'CODEX_HOME') &&
|
|
isBoundedString(home.path, MAX_PATH_LENGTH)
|
|
)
|
|
}
|
|
|
|
export function isAgentSessionOptions(value: unknown): value is Record<string, string> {
|
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
return false
|
|
}
|
|
const entries = Object.entries(value)
|
|
return (
|
|
entries.length <= 32 &&
|
|
entries.every(
|
|
([key, option]) =>
|
|
isBoundedString(key, MAX_ID_LENGTH) && isBoundedString(option, MAX_ID_LENGTH)
|
|
)
|
|
)
|
|
}
|
|
|
|
export function isAgentSessionLaunchEnv(value: unknown): value is AgentSessionLaunchEnv {
|
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
return false
|
|
}
|
|
const entries = Object.entries(value)
|
|
return (
|
|
entries.length <= MAX_LAUNCH_ENV_ENTRIES &&
|
|
entries.every(
|
|
([key, entry]) =>
|
|
isBoundedString(key, MAX_ID_LENGTH) &&
|
|
typeof entry === 'string' &&
|
|
entry.length <= MAX_LAUNCH_ENV_VALUE_LENGTH
|
|
)
|
|
)
|
|
}
|
|
|
|
function isAgentSessionJournalCheckpoint(value: unknown): value is AgentSessionJournalCheckpoint {
|
|
if (typeof value !== 'object' || value === null) {
|
|
return false
|
|
}
|
|
const checkpoint = value as Partial<AgentSessionJournalCheckpoint>
|
|
return (
|
|
Number.isSafeInteger(checkpoint.epoch) &&
|
|
(checkpoint.epoch as number) >= 0 &&
|
|
Number.isSafeInteger(checkpoint.sequence) &&
|
|
(checkpoint.sequence as number) >= 0
|
|
)
|
|
}
|
|
|
|
function isAgentSessionDeathEvidence(value: unknown): value is AgentSessionDeathEvidence {
|
|
if (typeof value !== 'object' || value === null) {
|
|
return false
|
|
}
|
|
const evidence = value as Partial<AgentSessionDeathEvidence>
|
|
return (
|
|
(evidence.kind === 'exit-observed' ||
|
|
evidence.kind === 'pid-absent' ||
|
|
evidence.kind === 'identity-mismatch') &&
|
|
isBoundedString(evidence.detail, MAX_ID_LENGTH) &&
|
|
Number.isSafeInteger(evidence.observedAt) &&
|
|
(evidence.observedAt as number) >= 0
|
|
)
|
|
}
|
|
|
|
function isAgentSessionLease(value: unknown): value is AgentSessionLease {
|
|
if (typeof value !== 'object' || value === null) {
|
|
return false
|
|
}
|
|
const lease = value as Partial<AgentSessionLease>
|
|
return (
|
|
isAgentSessionId(lease.sessionId) &&
|
|
(lease.runtimeKind === 'native' || lease.runtimeKind === 'tui') &&
|
|
Number.isSafeInteger(lease.runtimeFence) &&
|
|
(lease.runtimeFence as number) >= 0 &&
|
|
(lease.handoffStage === null ||
|
|
lease.handoffStage === 'preparing' ||
|
|
lease.handoffStage === 'old-owner-stopped' ||
|
|
lease.handoffStage === 'new-owner-proving' ||
|
|
lease.handoffStage === 'recovering' ||
|
|
lease.handoffStage === 'manual-recovery') &&
|
|
(lease.provenHandleLinkId === null || isBoundedString(lease.provenHandleLinkId, 128)) &&
|
|
(lease.ownerProcess === null || isAgentSessionProcessIdentity(lease.ownerProcess)) &&
|
|
(lease.reservedSpawnToken === null ||
|
|
isBoundedString(lease.reservedSpawnToken, MAX_ID_LENGTH)) &&
|
|
(lease.processlessAt === undefined ||
|
|
lease.processlessAt === null ||
|
|
(Number.isSafeInteger(lease.processlessAt) && (lease.processlessAt as number) >= 0)) &&
|
|
Number.isSafeInteger(lease.leaseDeadlineAt) &&
|
|
Number.isSafeInteger(lease.lastRenewedAt) &&
|
|
(lease.handoffOperationId === null ||
|
|
isBoundedString(lease.handoffOperationId, MAX_ID_LENGTH)) &&
|
|
(lease.journalCheckpoint === null ||
|
|
isAgentSessionJournalCheckpoint(lease.journalCheckpoint)) &&
|
|
isBoundedString(lease.claimKeyId, MAX_ID_LENGTH) &&
|
|
(lease.claimStatus === 'reserved' ||
|
|
lease.claimStatus === 'live' ||
|
|
lease.claimStatus === 'conflicted' ||
|
|
lease.claimStatus === 'released') &&
|
|
typeof lease.unreconciled === 'boolean' &&
|
|
(lease.settlementRetryRequired === undefined ||
|
|
typeof lease.settlementRetryRequired === 'boolean') &&
|
|
(lease.settlementRetryId === undefined ||
|
|
isBoundedString(lease.settlementRetryId, MAX_ID_LENGTH)) &&
|
|
(lease.deathEvidence === null || isAgentSessionDeathEvidence(lease.deathEvidence))
|
|
)
|
|
}
|
|
|
|
export function isAgentSessionRecord(value: unknown): value is AgentSessionRecord {
|
|
if (typeof value !== 'object' || value === null) {
|
|
return false
|
|
}
|
|
const record = value as Partial<AgentSessionRecord>
|
|
const shapeValid =
|
|
record.schemaVersion === AGENT_SESSION_RECORD_SCHEMA_VERSION &&
|
|
isAgentSessionId(record.sessionId) &&
|
|
isAgentSessionExecutionLocation(record.location) &&
|
|
(record.provider === 'claude' || record.provider === 'codex') &&
|
|
isAgentSessionProviderHandleChain(record.providerHandleChain) &&
|
|
isAgentSessionAccountHome(record.accountHome) &&
|
|
(record.options === undefined || isAgentSessionOptions(record.options)) &&
|
|
(record.launchArgs === undefined || isAgentSessionLaunchArgs(record.launchArgs)) &&
|
|
!Object.hasOwn(record, 'launchEnv') &&
|
|
isAgentSessionLease(record.lease) &&
|
|
record.lease.sessionId === record.sessionId &&
|
|
Number.isSafeInteger(record.createdAt) &&
|
|
Number.isSafeInteger(record.updatedAt)
|
|
if (!shapeValid) {
|
|
return false
|
|
}
|
|
const validated = record as AgentSessionRecord
|
|
const head = validated.providerHandleChain.at(-1)
|
|
return (
|
|
validated.providerHandleChain.every((link) => link.handle.provider === validated.provider) &&
|
|
(validated.lease.claimStatus !== 'live' ||
|
|
(validated.lease.ownerProcess !== null &&
|
|
head?.linkId === validated.lease.provenHandleLinkId &&
|
|
head.mintedAtFence === validated.lease.runtimeFence))
|
|
)
|
|
}
|
|
|
|
export function isAgentSessionLaunchArgs(value: unknown): value is AgentSessionLaunchArgs {
|
|
return (
|
|
Array.isArray(value) &&
|
|
value.length <= MAX_LAUNCH_ARGS &&
|
|
value.every((arg) => typeof arg === 'string' && !arg.includes('\0')) &&
|
|
Buffer.byteLength(JSON.stringify(value), 'utf8') <= MAX_LAUNCH_ARGS_BYTES
|
|
)
|
|
}
|