Files
orca/src/shared/agent-session-record.ts
T
Neil 231e805b1e fix(lint): enable anti-slop/no-shape-in-symbol-names (#20785)
Flip `anti-slop/no-shape-in-symbol-names` from "off" to "error" and clear
every violation under src, config, tests and mobile.

What the rule bans
------------------
The case-insensitive substring "shape" in any JS/TS identifier: variables,
functions, parameters, types, type parameters, class members, private names,
object-literal keys and JSX identifiers. The one exemption is a statically
accessed member read owned by another value (`zodObject.shape` is fine), so
third-party APIs stay readable without a suppression.

"Shape" names a value's structure rather than its domain role. `UserShape`,
`validateArgShape` and `errorShape` all tell you the symbol is "an object
with some fields" -- which is already what a type says -- while saying
nothing about what the value is for or who owns it. The rule forces the
name to carry the domain instead.

Violations fixed
----------------
689 violations across 109 files at baseline (verified by re-running the
audit against the pre-change tree with the rule set to "error").

Fix pattern
-----------
Rename for the domain role, not the structure:

  -type FieldShape = 'list' | 'map' | 'whole'
  -const FIELD_SHAPES = { ... } satisfies Record<keyof Observation, FieldShape>
  +type FieldEncoding = 'list' | 'map' | 'whole'
  +const FIELD_ENCODINGS = { ... } satisfies Record<keyof Observation, FieldEncoding>

  -function assertGitPushTargetShape(target: unknown): void
  +function assertValidGitPushTarget(target: unknown): void

  -function describeReadDirPathShape(p: string): ReadDirPathKind
  +function classifyReadDirPath(p: string): ReadDirPathKind

Predicates became statements about the value (`isDeltaShapedProviderFrameKind`
-> `isDeltaProviderFrameKind`, `isDeleteShapedDiscardEntry` ->
`discardDeletesEntryFile`, `isSkillsCliAgentKeyShaped` ->
`isUsableSkillsCliAgentKey`). Type aliases dropped the suffix where the
remaining name was already unambiguous (`GhGraphqlErrorShape` ->
`GhGraphqlError`).

No wire-visible name was renamed: no IPC or RPC channel, stream opcode,
request/response param, persisted field, or i18n key. The `--shape=symlink|copy`
CLI flag read by .github/workflows/skill-update-roundtrip.yml is unchanged --
only the local variable holding it was renamed.

Exemptions
----------
They are file-scoped entries in config/oxlint-anti-slop.json, not inline
`oxlint-disable` comments. An inline directive naming an anti-slop rule reads
back as an UNUSED directive under the root lint scan, which does not load this
plugin -- the changed-code quality gate counts that warning, so the comment form
cannot be used for a rule that lives only in this config.

* src/renderer/src/components/browser-pane/annotate/**:
  in the screenshot annotator a "shape" is the drawn geometry -- pen, arrow,
  rect, ellipse, highlight. That is a genuine domain noun, and it pervades
  every symbol in the module.
* repo-icon.tsx, repo-header-project-actions.tsx, mobile MobileRepoIcon.tsx:
  lucide exports the icon component as `Shapes`. The name is theirs, and the
  matching REPO_LUCIDE_ICONS key is the persisted icon name shared with the
  desktop picker -- renaming it would orphan saved repo icons.
* src/shared/onboarding-state-types.ts, src/shared/constants.ts:
  `shapedSidebar` is a persisted onboarding-checklist field and a telemetry
  enum member; renaming it would orphan saved state.
* src/shared/rpc-contract/rpc-send-params.ts: matching zod's own literal `shape`
  property is what selects the ZodObject branch of the conditional type.

No exemption was added merely to avoid a rename. Eight symbols initially
suppressed as "a cross-module refactor outside this change" were proven to have
zero non-TypeScript references repo-wide and renamed instead.

Zod's `ZodRawShape` needed no exemption at all: `Readonly<Record<string,
z.ZodType>>` is its definition, so repo-update-params.ts and
ui-update-value-tolerance-params.ts spell it out instead. Likewise
telemetry-event-classification.ts now reads `.shape` through an `in` narrowing,
which also retires two pre-existing type assertions; three more assertions the
rename had dragged onto changed lines (two `JSON.parse` sites, one node:sqlite
row read) became annotations and an explicit row mapping.

Verified
--------
* Audit reports zero violations; confirmed the rule genuinely fires by
  planting a probe violation.
* node config/scripts/run-typecheck-projects-in-parallel.mjs exits 0.
* Vitest over src/shared, src/main/github/project-view, the annotate module,
  the repo-icon components and the Chromium SameSite electron spec: all green.
* All 66 removed "shape" identifiers grepped repo-wide across every file type;
  none survive.
* node config/scripts/generate-rpc-params-catalog.mjs --check exits 0.
* node --check on every changed .mjs; oxfmt clean on all changed files.
* `pnpm run check:code-quality:changed` reports 0 findings.

Not machine-verified: the 3 mobile/ files (its Vitest run cannot resolve
`expo/tsconfig.base.json` in this worktree), and the WSL- and Playwright-gated
specs. All are rename- or comment-only hunks, read in full.
2026-09-15 02:00:27 -07:00

381 lines
15 KiB
TypeScript

import { isAgentSessionRewindRecord, type AgentSessionRewindRecord } from './agent-session-rewind'
import { isAgentSessionConversationName } from './agent-session-conversation-name'
/**
* 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[]
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>
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 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 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') &&
isAgentSessionLease(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))
)
}
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
)
}