mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
* fix(native-chat): report a failed startup chat reconcile instead of failing app startup At startup the chat host re-checks every saved chat's lease and writes the result to agent-sessions.json. If that write failed (the file lock gave up, the file could not be written, or the file was written by a newer Orca and is read-only here), reconcileRestartLeases rejected, the startup IPC call rejected, and the renderer fell into its degraded "Session restore failed. Changes won't be saved until restart" mode. The reconcile is bookkeeping: a lease left unreconciled grants no writer, and every attach, send and read of a chat reconciles its own lease again. So the startup reconcile now reports its failure through a new optional host dependency, onStartupReconcileFailure, and resolves. The runtime routes it to its onError sink under the scope structured-agent-session-startup-reconcile, or logs it when no sink is installed (the desktop installs none). * fix(native-chat): read restored chats without waiting on lease bookkeeping With native chat on and a chat tab open at quit, the renderer's startup also awaits the chat tab restore (session.tabs.listAll). That restore re-ran the lease reconcile before reading each chat and rethrew its store failure, then recorded each restored tab as visible through a store transaction that throws on a held lock or a read-only store. Either one failed the restore, so startup still fell into "Session restore failed". Reading a chat grants no writer, so the reconcile startup and the restore run is now a reader's: createReaderReconcile never throws, answers whether every lease is settled (recovery is resolved only then; the journal opens either way), and reports each distinct failure once until a reconcile settles. Attach and agent start keep the strict reconcile. The restore's tab republish logs a failed visibility write and still publishes the tab, since a client drops every unpublished chat tab; user-driven publishes still refuse. The host dependency is renamed onLeaseReconcileFailure (scope structured-agent-session-lease-reconcile), since it now also reports for reads. * fix(native-chat): keep every record-store write off the startup chat read path Round-2 review found two more writes on the startup chat restore that could still fail it and put the app into "Session restore failed": republishing a /clear replacement recorded its tab visibility strictly, and resolving a chat's recovery rethrew its store error. The restore also paid one lock wait per tab and per batch of chats while the lock stayed held. The restore now derives tabs from state it already holds: - publishStructuredAgentSessionTab splits into the strict write and projectStructuredAgentSessionTab, which only updates the runtime's snapshot. The restore and /clear replacements only project: a saved tab index already lists every restored chat, and a /clear moves the tab in the same write that commits it. visibilityWriteMayFail is gone. - Chats a legacy profile restores that the index does not list are recorded in one best-effort transaction (store.showSessionTabs), so a failure leaves the index absent to seed again rather than partial. - The read restore's recovery resolution is caught and reported through onLeaseReconcileFailure, deduplicated with the reconcile's reports. - Once lease bookkeeping fails in a restore pass, the rest of that pass skips it, so a held lock costs one wait for the startup reconcile and one for the restore, however many chats are open. User actions (create, reveal, attach, send, the /clear commit) keep their strict writes. * test: open, seed and read the agent-session record store through one harness Tests that open the durable agent-session record store, seed it, or read back what it persisted now go through agent-session-record-store-test-harness.ts instead of calling AgentSessionRecordStore.open or touching agent-sessions.json themselves. A later change that moves the store into the chat database then changes the harness instead of every test. No production code changes. Tests whose subject is the JSON file itself (its .bak recovery, salvage, schema versions, permissions, and what older builds read back) keep reading and writing the file directly; the storage move rewrites or deletes them. * fix(native-chat): start each restore pass from one lease check and stop its bookkeeping at the first failure The restore now runs one reader lease check for the pass and lets each chat re-check and resolve recovery only while the pass is still settled. The first refusal or failed write clears it for the rest of the pass, and every chat is still opened for reading. With another process holding the lock, startup waits on it once in prepare and once in the restore, however many chats are open; a legacy profile waits once more for its tab-index seed. * docs(native-chat): correct restore comments and a test name to match the final design * test: address the record-store harness by the host's state directory The harness took the store's own folder, so each caller picked one (join(root, 'store'), or 'agent-sessions' where a test read the store the runtime owns). A later change that moves the store into the state directory's journal database could not tell those apart, and would have had to edit every caller again. Every harness function now takes the state directory, the one the test's journal database and recovery capsule already live in, and keeps the store in the same subfolder the runtime uses. Callers pass that directory; store-only tests pass their temp directory unchanged. Format tests that share a directory with harness calls take the file path from testAgentSessionStoreFilePath. The folder name moves from a private constant in the runtime to AGENT_SESSION_STORE_DIR_NAME beside the store's file name, so the harness shares it without importing the runtime. Its value and every path built from it are unchanged. * refactor(native-chat): keep agent-session records in the chat journal database The record store's records, operation ledger, retired claim keys and chat tab index become tables in agent-session-journal.db (user_version 4). The version-4 migration copies agent-sessions.json in its own transaction and never writes, renames or deletes that file or its .bak. Each store write is one journal transaction over exactly the rows it changed, checked with the load rules; the file lock, the external-change refresh and its hash, the .bak rotation, salvage and the hot-path recovery fence are gone from the store. * wip: importer tests * test(native-chat): cover the records migration, the import, row writes and read-only records * docs(native-chat): retire comments that describe the records file as the live store * test(native-chat): drop the record-store harness's leftover file path and type the import fixture * test(native-chat): let the host harness cleanup wait out a recovery-offer read's lock * fix(native-chat): let Stop reach the agent when its ledger row cannot be written Stop's operation-ledger row now shares the database with the chat history, so damage, a full disk or a stranded transaction on that write refused the Stop before the interrupt. A cancel plan now takes its decision from the committed ledger in memory, runs without settling, and warns that the row was skipped. Other mutations answer proven damage with the typed "Unable to load this chat." refusal instead of the raw SQLite error. * fix(native-chat): answer whether a profile holds chats from the database's rows Every host install creates agent-session-journal.db, chats or not, and the version probe created it too, so its mere existence made every profile that ever installed the host wait on host install and reconcile at startup. The check now opens the database read-only and looks for a record or tab row, lets the records file answer while its import is still owed, and counts an unreadable database as present. The version probe no longer creates the file. * fix(native-chat): open a chat from history when its tab index cannot be written Over records a newer Orca wrote, every write is refused, so opening a closed chat from Agent Session History failed on the tab-visibility write and the chat read as unreachable. Like closing a tab, opening one now reports a failed restore-index write and still publishes the tab. * fix(native-chat): keep the records import owed when the backup read fails transiently A torn records file whose .bak could not be read (EACCES, EIO) was reported as unusable, so the migration completed with nothing copied and never retried. A non-ENOENT read failure of either copy now carries its cause, which the importer classifies as a read that can clear. * test(native-chat): pin that an unreadable records file never falls back to its backup * fix(native-chat): restore imported chats' tabs when the records file had no tab index A chat created while the import was owed recorded a tab index holding only itself. When the file it later imported had no index, that index still read as recorded, so the imported chats' tabs never came back. The import now clears the recorded marker in that case, and restore falls back to the profile's tabs. * refactor(native-chat): drop the unused in-transaction store write Nothing called it, and it bypassed the write queue and the read-only refusal. * docs(native-chat): say that an unusable records file is left untouched but never re-imported * refactor(native-chat): keep the provider handle chain check as main has it The chain-validation refactor has no measured need in this change. * docs(native-chat): retire lease-renewer comments that describe the records file as the live store * fix(native-chat): keep a throwing failure sink from failing the startup chat read The lease bookkeeping failure reporter called the host's failure sink directly, so a sink that threw turned a reported, recoverable store failure back into a rejected startup reconcile or read restore. The reporter now catches a sink throw and logs both the original failure and the sink error with console.warn. * test(native-chat): wait for a replaced host's restart-offer writes before cleanup A restart test replaces the host without tearing the old one down, so the old host's fire-and-forget restart-offer withdrawal could still hold the recovery capsule's lock directory when cleanup removed the test directory (ENOTEMPTY). The harness now hands hosts a capsule that tracks running operations and waits for them before removing the directory, replacing the rm retries. * docs(native-chat): retire the abandon helper's note that the store re-creates its directory * fix(native-chat): restore a chat opened while the import was owed beside the profile's chats When the imported records file had no tab index, restore fell back to the profile's saved tabs, which never list a Claude chat, and the seed then rewrote the tab table without the chat opened while the import was owed. The tab rows that chat left are now loaded as unrecorded, restore takes them together with the profile's chats, and the seed keeps their tab ids. * test(native-chat): pin that a create whose tab index write fails still opens the chat * docs(native-chat): say why restore puts chats opened while the import was owed first * test(native-chat): replace a ledger row rather than change it in place in the Send-now rerun test The record store freezes published rows in tests, so setting a row's outcome in place threw; the test now swaps in a changed copy, as its sibling cases do.
377 lines
15 KiB
TypeScript
377 lines
15 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
|
|
/** Fence of the owner (or reservation) this death is about; a fence names exactly one. Absent on
|
|
* evidence older builds wrote, which then speaks for no turn. */
|
|
ownerFence?: number
|
|
/** The death interval's lower bound: the last time the runtime holding the owner's transport
|
|
* proved it alive. Only a probe's proof records it: absent on a surface-release exit, a failed
|
|
* start, and evidence older builds wrote. */
|
|
lastProvenAliveAt?: 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
|
|
/** While `ownerProcess` is set, the last time its transport holder proved it alive; parking in
|
|
* `recovering` proves nothing, so it leaves this alone. */
|
|
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 when the records file's copy came from its backup,
|
|
* or sat beside a set-aside copy of the same chat: either may hide a fence already granted. 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>
|
|
const { observedAt, lastProvenAliveAt, ownerFence } = evidence
|
|
return (
|
|
(evidence.kind === 'exit-observed' ||
|
|
evidence.kind === 'pid-absent' ||
|
|
evidence.kind === 'identity-mismatch') &&
|
|
isBoundedString(evidence.detail, MAX_ID_LENGTH) &&
|
|
typeof observedAt === 'number' &&
|
|
Number.isSafeInteger(observedAt) &&
|
|
observedAt >= 0 &&
|
|
(ownerFence === undefined || (Number.isSafeInteger(ownerFence) && ownerFence >= 0)) &&
|
|
(lastProvenAliveAt === undefined ||
|
|
(Number.isSafeInteger(lastProvenAliveAt) &&
|
|
lastProvenAliveAt >= 0 &&
|
|
lastProvenAliveAt <= observedAt))
|
|
)
|
|
}
|
|
|
|
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))
|
|
)
|
|
}
|