Files
orca/src/main/runtime/orca-runtime-state-fields.ts
T
420447e063 fix(agent-status): retire a removed worktree's hook-status rows (#23881)
* fix(agent-status): retire a removed worktree's hook-status rows

Rows whose terminal was never reattached had no teardown path, so they
outlived the worktree in last-status.json for up to 7 days.

Fixes #23068

* fix(agent-status): skip panes another owner has since reclaimed

* fix(agent-status): clear only the removed owner's claims on a shared pane

* fix(agent-status): retire hook-status rows a host scan proved removed

`worktrees:forgetRemovedForExecutionHost` is the only path that ever retires an
off-host WorktreeMeta row: gcStaleWorktreeMeta skips any row whose repo or
hostId is not local. It already prunes the cleanup and space-analysis snapshots
for a worktree a remote scan proved gone, but left that worktree's hook-status
rows behind in `last-status.json` — the same stranding this branch fixes for the
in-Orca delete, reached through the other trigger.

A scan the host answered is positive evidence of removal rather than loss of
contact, so it is the host evidence `ssh-execution-boundary.md` requires, and it
publishes no verdict. The drop is scoped to the scanned host's id, so a same-id
worktree on another connection keeps its rows, and a runtime host falls through
the method's own early return because a paired server owns its own store.

Also names the shared-pane condition in `dropStatusEntriesForRemovedWorktree`,
which was the one place the two-owner logic was hard to read.

* fix(agent-status): stop a removed worktree's row returning on a shared pane

The mixed-owner branch deleted the removed worktree's row but left the pane
unfenced, so the stale row came straight back. `getAgentStatusDisposition`
returns `accept` for an unfenced pane, and the removed worktree's agent can
still post a late turn on a pane it shares with another owner — I reproduced the
`working` row being rewritten to memory and to `last-status.json`, which is the
symptom #23068 is about.

A launch-token fence cannot close this: `ingestTerminalStatus` calls the
disposition gate with no event, so the token check never runs and an OSC report
carries no token to check. The pane fence the sole-owner path already uses does
suppress it, and it lifts on PTY reattach or a new agent's turn.

Fencing the pane would otherwise discard the surviving owner's claims, which is
what the branch existed to protect, so both of its records are captured first
and restored after: its persisted authority commitment (its resume identity) and
its current authority observation. The observation also fixes a second defect on
this path — `deleteStatusEntry` drops it whatever `preserveAuthority` says, so
the surviving owner silently lost `current_runtime` attestation until its next
hook event.

Both are covered by tests that fail against the previous commit.

* fix(agent-status): decide a reused pane by its occupant, and retire rows for local scan-proved removals

A pane has one terminal, so its newest row names who occupies it. A saved
commitment naming a different owner is what an earlier occupant left behind,
and serialization already drops it while the row disagrees. Removal now retires
a pane the removed worktree occupies, and on a pane another owner occupies it
clears only the removed worktree's outlived commitment. This replaces the
capture-and-restore handling of two-owner panes.

The local authoritative-scan prune is the local twin of the SSH scan forget: it
drops a worktree's metadata once the scan proves it gone, and now retires its
status rows too.

* fix(agent-status): preserve foreign authority during worktree removal

* fix(agent-status): preserve terminal connection validation

* fix(agent-status): keep ordinary OSC admission unchanged

* refactor(agent-status): colocate the remote envelope type

* fix(agent-status): revoke removed startup authority evidence

* fix(agent-status): retain foreign startup claims during removal

---------

Co-authored-by: Neil <neil@stably.ai>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-10-05 20:12:38 -07:00

282 lines
16 KiB
TypeScript

// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithLinearCommands } from './orca-runtime-linear-commands'
import type { ExecutionHostScope } from '../../shared/execution-host'
import type { RuntimeStore } from './runtime-store-contract'
import type { StatsCollector } from '../stats/collector'
import type { IPtyProvider } from '../providers/types'
import type { PrepareClaudeAuth } from '../ipc/pty/host-env/types'
import type { RuntimeTerminalAgentStatusEvent } from './runtime-terminal-contracts'
import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts'
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-feed'
import type { ObservedAgentStatusPaneIdentity } from '../ipc/agent-status-ipc-boundary'
import type { AgentHookAuthorityAttestation } from '../agent-hooks/server'
import type {
AiVaultPrepareSessionResumeArgs,
AiVaultPrepareSessionResumeResult
} from '../../shared/ai-vault-resume-preparation'
import type { RuntimeDesktopWindowStatus } from '../../shared/runtime-types'
import type { AgentSessionClaimSigner } from './agent-session-claim-identity'
import type { OrchestrationEnvironmentTransport } from './orchestration/environment-transport'
import type { RuntimeCommandSurfaceHost } from './orca-runtime-core'
import { installRuntimeFileCommandSurface } from './runtime-file-command-surface'
import { installRuntimeGitCommandSurface } from './runtime-git-command-surface'
import { installRuntimeRepositoryCommandSurface } from './runtime-repository-command-surface'
import { installRuntimeReviewCommandSurface } from './runtime-review-command-surface'
import { installRuntimeServiceCommandSurface } from './runtime-service-command-surface'
import {
RuntimeSkillCommands,
installRuntimeSkillCommandSurface
} from './runtime-skill-command-surface'
import { getAppEnvironment } from '../../shared/app-environment'
import { RuntimeClientSettingsController } from './runtime-client-settings'
import {
RuntimeSessionSearchSettingsController,
type SessionSearchSettingsApply
} from './runtime-session-search-settings'
import { RuntimeAutomationController } from './runtime-automation-controller'
import { RuntimeOrchestrationFederation } from './runtime-orchestration-federation'
import { configureAiVaultSessionSources } from '../ai-vault/cached-session-list'
import { configureHostReadableTranscriptPathSources } from '../native-chat/host-readable-transcript-path'
import { createEphemeralAgentSessionClaimSigner } from './agent-session-claim-identity'
import { registerConptyDa1OverrideInstaller } from './terminal-model-query-authority'
import { registerTerminalViewAttributesApplier } from './terminal-view-attribute-store'
import { RuntimeMachineName } from './runtime-machine-name'
export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands {
protected readonly prepareClaudeAuth?: PrepareClaudeAuth
protected readonly machineName = new RuntimeMachineName(
() => this.store?.getSettings?.().machineName
)
constructor(
store: RuntimeStore | null = null,
stats?: StatsCollector,
deps?: {
getLocalProvider?: () => IPtyProvider
getSshProvider?: (connectionId: string) => IPtyProvider | undefined
prepareClaudeAuth?: PrepareClaudeAuth
onPtyStopped?: (ptyId: string) => void
onTerminalAgentStatus?: (event: RuntimeTerminalAgentStatusEvent) => void
onTerminalSideEffects?: (batch: TerminalSideEffectBatch) => void
// Why: agent status mostly arrives via hooks (agent-hooks/server), not OSC
// terminal output. worktree.ps reads this at query time so mobile shows the
// same inline agent rows the desktop sidebar does — same source, 1:1.
getAgentStatusSnapshot?: () => AgentStatusIpcPayload[]
/** Where structured (native chat) sessions publish into that same store, so the snapshot
* above lists them like every other agent. */
structuredAgentStatusSink?: StructuredAgentSessionStatusSink
/** The identity the runtime resolved for a pane as each status arrived. Without it the
* fleet path reminted cached rows against whatever the pane owns now. */
readObservedAgentStatusPaneIdentity?: (paneKey: string) => ObservedAgentStatusPaneIdentity
/** Same rows, but including the resume-identity-only ones `getAgentStatusSnapshot`
* filters out so they can't read as running agents. Mobile native chat needs
* them: for an agent that publishes identity separately (Pi), that row is the
* only carrier of the provider session a transcript is addressed by. */
getAgentProviderSessionSnapshot?: () => AgentStatusIpcPayload[]
getAgentProviderSessionRowsForPane?: (paneKey: string) => AgentStatusIpcPayload[]
attestAgentHookCompatibilityAuthority?: (candidate: {
paneKey: string
launchTokenHash: string
connectionId: string | null
terminalProvenance: 'current_runtime' | 'restored'
}) => AgentHookAuthorityAttestation | null
retireAgentHookCompatibilityAuthority?: (paneKey: string) => void
checkHookAgentPresence?: (
paneKey: string
) => Promise<'live' | 'unverifiable' | 'exited' | null>
reconcileAgentStatusForEndedProcess?: (paneKeys: Iterable<string>) => void
dropAgentStatusForRemovedWorktree?: (worktreeId: string, host?: ExecutionHostScope) => void
canRecoverPersistentLocalPtys?: () => boolean
// Why: the device registry lives on the RPC server, which is constructed with this runtime;
// a closure defers the lookup past that ordering instead of inverting ownership.
getPairedDeviceName?: (pairedDeviceId: string) => string | null
// Why: codex-home paths for the Agent Session History scan must be sourced
// here, not via the window-only registerCoreHandlers path — that path never
// runs under `orca serve`, so remote/SSH hosts would silently drop
// managed-Codex sessions. The runtime ctor runs in BOTH window and serve.
getAdditionalAiVaultCodexHomePaths?: () => readonly string[]
prepareAiVaultSessionResume?: (
args: AiVaultPrepareSessionResumeArgs
) => Promise<AiVaultPrepareSessionResumeResult>
prepareCodexStructuredLaunch?: (input: {
launchEnv: NodeJS.ProcessEnv
}) => string | null | Promise<string | null>
// Why a sibling of prepare: record-less catalog reads must resolve the
// same launch home with none of launch prep's side effects (no sync, no
// bridge, no cleared selection).
resolveCodexStructuredLaunchHome?: (input: {
launchEnv: NodeJS.ProcessEnv
}) => string | null | Promise<string | null>
buildAgentHookPtyEnv?: () => Record<string, string>
getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus
agentSessionClaimSigner?: AgentSessionClaimSigner
skillTransactionRecovery?: Promise<unknown>
// Why a host hook and not a direct call: the process that owns this runtime's index
// differs per host (scanner child on the desktop, in-process on orcad), and on orcad
// it is installed after construction, so the closure has to resolve it at call time.
applySessionSearchSettings?: SessionSearchSettingsApply
orchestrationEnvironmentTransport?: OrchestrationEnvironmentTransport
}
) {
super()
this.store = store
this.machineName.start()
this.prepareClaudeAuth = deps?.prepareClaudeAuth
const runtime = this as RuntimeCommandSurfaceHost<this>
installRuntimeFileCommandSurface(runtime, this.fileCommands)
installRuntimeGitCommandSurface(runtime, this.gitCommands)
installRuntimeRepositoryCommandSurface(runtime, {
projectHostSetups: this.projectHostSetups,
projectGroups: this.projectGroups,
nestedRepoImport: this.nestedRepoImport,
serverEnvironment: this.serverEnvironment,
repositorySparsePresets: this.repositorySparsePresets,
repositoryRegistrations: this.repositoryRegistrations,
repositoryClones: this.repositoryClones,
repositorySettings: this.repositorySettings,
repositoryRefQueries: this.repositoryRefQueries,
hostedReviews: this.hostedReviews,
gitHubRepositoryQueries: this.gitHubRepositoryQueries,
repositoryHooks: this.repositoryHooks,
repositoryIssueCommand: this.repositoryIssueCommand
})
installRuntimeReviewCommandSurface(runtime, {
gitLabQueries: this.gitLabQueryCommands,
gitLabMutations: this.gitLabMutationCommands,
gitHubReviewQueries: this.gitHubReviewQueries,
gitHubReviewMutations: this.gitHubReviewMutations,
gitHubIssueComments: this.gitHubIssueComments,
gitHubProjects: this.gitHubProjectCommands
})
installRuntimeServiceCommandSurface(runtime, {
aiVault: this.aiVault,
sessionSearchSettings: new RuntimeSessionSearchSettingsController(
store,
deps?.applySessionSearchSettings ?? null
),
clientEvents: this.clientEvents,
nativeChatDraftResolutions: this.nativeChatDraftResolutions,
subscriptions: this.subscriptions,
mobileNotifications: this.mobileNotifications,
accounts: this.accounts,
mobileSpeech: this.mobileSpeech,
mobileDictation: this.mobileDictation,
browserDrivers: this.browserDrivers,
messageWaiters: this.messageWaiters
})
this.skillCommands = new RuntimeSkillCommands({
getRuntimeId: () => this.runtimeId,
getUserDataPath: () => getAppEnvironment().getPath('userData'),
isPackaged: () => getAppEnvironment().isPackaged(),
getSettings: () => this.store?.getSettings?.() ?? {},
listRepos: () => this.listRepos(),
listFolderWorkspaces: () =>
(this.store?.getFolderWorkspaces?.() ?? []).map((workspace) => ({
id: workspace.id,
folderPath: workspace.folderPath,
connectionId: workspace.connectionId,
executionHostId: workspace.executionHostId
})),
listResolvedWorktrees: () => this.listResolvedWorktrees(),
showManagedWorktree: (selector) => this.showManagedWorktree(selector),
resolveProjectRuntimeForWorktree: (worktreeId) =>
this.resolveProjectRuntimeForWorktree(worktreeId),
getSshProvider: (connectionId) => this.getSshProviderFn?.(connectionId),
getClaudeConfigDirectory: (target) => this.accounts.getClaudeConfigDirectory(target),
skillTransactionRecovery: (deps?.skillTransactionRecovery ?? Promise.resolve()).catch(
(error) => {
console.warn('[skills] startup transaction recovery failed:', error)
}
)
})
installRuntimeSkillCommandSurface(runtime, this.skillCommands)
Object.assign(this, this.edgeCommands.surface)
// Why: keep cache-boundary test seams live while the fetch owner holds the mutable maps.
void this.canonicalFetchKeyCache
void this.fetchLastCompletedAt
this.clientSettings = new RuntimeClientSettingsController(store, () =>
this.notifyReposChanged()
)
this.automation = new RuntimeAutomationController(store, {
showRepo: (selector) => runtime.showRepo(selector),
showManagedWorktree: (selector) => this.showManagedWorktree(selector)
})
// Why: per-device tab selections must survive host restarts, or every phone snaps back to the first tab on return.
const persistedClientTabSelections = store?.getMobileClientTabSelections?.()
if (persistedClientTabSelections) {
this.clientSessionTabSelections.hydrate(persistedClientTabSelections)
}
this.clientSessionTabSelections.setPersistListener((state) => {
this.store?.setMobileClientTabSelections?.(state)
})
this.orchestrationEnvironmentTransport = deps?.orchestrationEnvironmentTransport ?? null
this.orchestrationFederation = new RuntimeOrchestrationFederation(
runtime,
this.orchestrationEnvironmentTransport
)
if (stats) {
this.stats = stats
}
this.getAgentStatusSnapshotFn = deps?.getAgentStatusSnapshot ?? null
this.structuredAgentStatusSinkFn = deps?.structuredAgentStatusSink ?? null
this.readObservedAgentStatusPaneIdentityFn =
deps?.readObservedAgentStatusPaneIdentity ?? (() => ({ kind: 'unobserved' }))
this.getAgentProviderSessionSnapshotFn =
deps?.getAgentProviderSessionSnapshot ?? deps?.getAgentStatusSnapshot ?? null
this.getAgentProviderSessionRowsForPaneFn = deps?.getAgentProviderSessionRowsForPane ?? null
this.attestAgentHookCompatibilityAuthorityFn =
deps?.attestAgentHookCompatibilityAuthority ?? null
this.retireAgentHookCompatibilityAuthorityFn =
deps?.retireAgentHookCompatibilityAuthority ?? null
this.checkHookAgentPresenceFn = deps?.checkHookAgentPresence ?? null
this.reconcileAgentStatusForEndedProcessFn = deps?.reconcileAgentStatusForEndedProcess ?? null
this.dropAgentStatusForRemovedWorktreeFn = deps?.dropAgentStatusForRemovedWorktree ?? null
this.canRecoverPersistentLocalPtysFn = deps?.canRecoverPersistentLocalPtys ?? (() => true)
this.getPairedDeviceNameFn = deps?.getPairedDeviceName ?? (() => null)
// Why: configure the shared AiVault scan cache from a serve-mode-reachable
// seam so the aiVault.listSessions RPC includes managed-Codex + WSL sessions
// even on headless `orca serve` hosts where registerCoreHandlers never runs.
if (deps?.getAdditionalAiVaultCodexHomePaths) {
configureAiVaultSessionSources({
getAdditionalCodexHomePaths: deps.getAdditionalAiVaultCodexHomePaths
})
configureHostReadableTranscriptPathSources({
getAdditionalCodexHomePaths: deps.getAdditionalAiVaultCodexHomePaths
})
}
// Why: the daemon adapter is installed via `setLocalPtyProvider()` during
// attachMainWindowServices, AFTER this service is constructed. Capturing
// `getLocalPtyProvider()` at construction time would freeze a reference to
// the pre-daemon `LocalPtyProvider` and miss the routed adapter. Resolve
// lazily via thunk so teardown always sees the currently-installed
// provider (design §4.3 wire-up).
this.getLocalProviderFn = deps?.getLocalProvider ?? null
this.getSshProviderFn = deps?.getSshProvider ?? null
this.onPtyStopped = deps?.onPtyStopped ?? null
this.onTerminalAgentStatus = deps?.onTerminalAgentStatus ?? null
this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null
this.getDesktopWindowStatusFn = deps?.getDesktopWindowStatus ?? (() => 'openable')
this.prepareAiVaultSessionResumeFn = deps?.prepareAiVaultSessionResume ?? null
this.prepareCodexStructuredLaunchFn = deps?.prepareCodexStructuredLaunch ?? null
this.resolveCodexStructuredLaunchHomeFn = deps?.resolveCodexStructuredLaunchHome ?? null
this.agentSessionClaimSigner =
deps?.agentSessionClaimSigner ?? createEphemeralAgentSessionClaimSigner(this.runtimeId)
this.onTerminalSideEffects = deps?.onTerminalSideEffects ?? null
// Why: the ConPTY spawn mark can land after daemon stream data already
// created this PTY's emulator; the mark retrofits the DA1 override here
// (terminal-query-authority.md §ConPTY DA1).
registerConptyDa1OverrideInstaller((ptyId) => this.ensureNativeWindowsConptyDa1Override(ptyId))
// Why: a renderer attribute push must reach already-live emulators too —
// cursor options for DECRQSS/DECRQM parity plus the per-PTY OSC color
// override reset a theme apply implies (terminal-query-authority.md
// §View-attribute bridge).
registerTerminalViewAttributesApplier((attributes) => {
for (const state of this.headlessTerminals.values()) {
state.emulator.applyPushedViewAttributes(attributes)
}
})
}
}