mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 08:02:12 +00:00
* fix(native-chat): every lease latch has a way to die A failed exit settlement no longer leaves the lease in recovery: the release writes no stage and keeps the exit in its death evidence, and whatever the dead generation left running is settled from that evidence at the next acquire or read restore. The settlement retry flag, its disposition and every branch that read it are gone. A reservation that recorded no process is released at startup and after a failed start, the never-written conflicted status and the processless proof are deleted, recovery resolution always concludes, and Codex records its child's identity at spawn, before the handshake. * test(native-chat): a re-create needs a release proven by death evidence * test(codex): the child's pid is reported before the handshake * test(native-chat): type the crash and exit fixtures without casts * fix(native-chat): wait out a terminal owner an older build recorded, in recovery rather than manual recovery * test(native-chat): a chat mid-turn at quit reopens idle, and an older build reads an unproven release * test(native-chat): explain the baseline store cast * fix(native-chat): a terminal owner's refusal names the process instead of recursing Opening a chat whose terminal owner an older build recorded threw a stack overflow instead of the refusal that names the process to quit. * fix(native-chat): wait out a terminal owner recovery cannot verify instead of releasing it A terminal agent an older build recorded keeps its PTY across an Orca restart, so a probe that cannot answer (a start-time read that fails on a loaded host) is not evidence its transport is gone. Releasing it let a native child resume the same conversation beside the live terminal agent. Only proof of its exit now ends the claim. * ci(cross-version): run the unproven-release downgrade test The sharded unit job excludes tests/e2e/cross-version-wire, and the cross-version job runs an explicit list that did not name the new test, so it never ran in CI. A change to the record validator now also starts the job. * refactor(native-chat): map the retired manual-recovery stage to recovering at decode Nothing in this build writes manual-recovery, and restart reconciliation already rewrites it. Mapping it where the other retired handoff stages are mapped removes it from the in-memory lease type and deletes the branches that could only see it: the acquisition refusal, the renewer skip, the unproven-release stage check, and the handoff-status 'manual recovery is required' answer. Older builds accept recovering, so a record written back still loads after a downgrade. * docs(native-chat): say what happens to a live child an ownerless reservation leaves The reaper runs once at store open, while the unreconciled lease still claims the child's token, so it does not stop that child on this launch. The comment claimed it did. * test(native-chat): name the each-case label for its role * fix(native-chat): continue a create retried after recovery released its reservation The client retries a create it never heard back from under the same operation id. Recovery had released that create's reservation, so the retry was refused agent_session_ownership_unknown while its row was pending, and agent_session_operation_expired once the row aged out, and the chat never started. A retry whose lease nothing holds now continues as a fresh reservation at the next fence, which also stops the old reservation's spawn from committing. * test(native-chat): name the refusal a replayed create used to get * fix(native-chat): one quit-the-terminal-agent message for a chat a terminal agent holds A chat held by a terminal agent an older build recorded frees only when that agent exits. Sending said to reopen the chat and opening it said two runtimes claimed it; both now say the chat is open in a terminal agent, name its process, and say to quit it. Error codes are unchanged. * ci: run PR checks on the rebased head * fix(native-chat): name a terminal owner's process only when its start time can tell it from a reused pid * test(native-chat): relaunch from the dying host's durable state, so its still-pending attach cannot race the new host
361 lines
14 KiB
TypeScript
361 lines
14 KiB
TypeScript
import { isAgentSessionRewindRecord, type AgentSessionRewindRecord } from './agent-session-rewind'
|
|
import { isAgentSessionLaunchArgs } from './agent-session-launch-args'
|
|
import { isAgentSessionConversationName } from './agent-session-conversation-name'
|
|
import {
|
|
isPersistedAgentSessionHandoffStage,
|
|
isPersistedAgentSessionRuntimeKind,
|
|
type PersistedAgentSessionLease,
|
|
type PersistedAgentSessionRecord
|
|
} from './agent-session-legacy-handoff-lease'
|
|
/**
|
|
* 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 {
|
|
isAgentSessionConversationCommandRecord,
|
|
type AgentSessionConversationCommandRecord
|
|
} from './agent-session-conversation-command'
|
|
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[]
|
|
|
|
/** Still persisted because older builds read it. The removed terminal handoff's `tui` is mapped
|
|
* away at decode (agent-session-legacy-handoff-lease). */
|
|
export type AgentSessionOwnerRuntimeKind = 'native'
|
|
|
|
/** The acquisition stage. Stages only older builds wrote are mapped away at decode. */
|
|
export type AgentSessionHandoffStage = 'new-owner-proving' | 'recovering'
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export type AgentSessionProcessIdentity = {
|
|
hostId: string
|
|
pid: number
|
|
processStartTimeMs: number | null
|
|
spawnToken: string
|
|
}
|
|
|
|
export type AgentSessionJournalCheckpoint = { epoch: number; sequence: number }
|
|
|
|
/**
|
|
* `released` means no owner: a durable record that outlives its owner needs a name for that.
|
|
* `conflicted` is how a terminal owner an older build recorded loads: recovery waits it out and
|
|
* never stops it, because it is the user's own agent.
|
|
*/
|
|
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
|
|
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
|
|
/** Null on a released lease when nothing proved its owner gone. */
|
|
deathEvidence: AgentSessionDeathEvidence | null
|
|
}
|
|
|
|
export type AgentSessionRecord = {
|
|
schemaVersion: typeof AGENT_SESSION_RECORD_SCHEMA_VERSION
|
|
sessionId: string
|
|
location: AgentSessionExecutionLocation
|
|
provider: AgentSessionHandleProvider
|
|
providerHandleChain: AgentSessionProviderHandleLink[]
|
|
accountHome: AgentSessionAccountHome
|
|
/** Provider options the user chose, replayed whenever a new owner starts the session. */
|
|
options?: Record<string, string>
|
|
rewind?: AgentSessionRewindRecord
|
|
conversationCommand?: AgentSessionConversationCommandRecord
|
|
/** The name Orca gave this conversation, so a later acquisition need not name it again. */
|
|
conversationName?: 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 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 isPersistedAgentSessionLease(value: unknown): value is PersistedAgentSessionLease {
|
|
if (typeof value !== 'object' || value === null) {
|
|
return false
|
|
}
|
|
const lease = value as Partial<AgentSessionLease>
|
|
return (
|
|
isAgentSessionId(lease.sessionId) &&
|
|
isPersistedAgentSessionRuntimeKind(lease.runtimeKind) &&
|
|
Number.isSafeInteger(lease.runtimeFence) &&
|
|
(lease.runtimeFence as number) >= 0 &&
|
|
(lease.handoffStage === null || isPersistedAgentSessionHandoffStage(lease.handoffStage)) &&
|
|
(lease.provenHandleLinkId === null || isBoundedString(lease.provenHandleLinkId, 128)) &&
|
|
(lease.ownerProcess === null || isAgentSessionProcessIdentity(lease.ownerProcess)) &&
|
|
(lease.reservedSpawnToken === null ||
|
|
isBoundedString(lease.reservedSpawnToken, MAX_ID_LENGTH)) &&
|
|
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.deathEvidence === null || isAgentSessionDeathEvidence(lease.deathEvidence))
|
|
)
|
|
}
|
|
|
|
/** The on-disk shape, which still admits the removed terminal handoff's lease values. Decode
|
|
* through `normalizeLegacyHandoffRecord` before anything reads the lease. */
|
|
export function isPersistedAgentSessionRecord(
|
|
value: unknown
|
|
): value is PersistedAgentSessionRecord {
|
|
if (typeof value !== 'object' || value === null) {
|
|
return false
|
|
}
|
|
const record = value as Partial<AgentSessionRecord>
|
|
const fieldsValid =
|
|
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.rewind === undefined || isAgentSessionRewindRecord(record.rewind)) &&
|
|
(record.conversationCommand === undefined ||
|
|
isAgentSessionConversationCommandRecord(record.conversationCommand)) &&
|
|
(record.conversationName === undefined ||
|
|
isAgentSessionConversationName(record.conversationName)) &&
|
|
(record.launchArgs === undefined || isAgentSessionLaunchArgs(record.launchArgs)) &&
|
|
!Object.hasOwn(record, 'launchEnv') &&
|
|
isPersistedAgentSessionLease(record.lease) &&
|
|
record.lease.sessionId === record.sessionId &&
|
|
Number.isSafeInteger(record.createdAt) &&
|
|
Number.isSafeInteger(record.updatedAt)
|
|
if (!fieldsValid) {
|
|
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))
|
|
)
|
|
}
|