mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
* 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>
282 lines
16 KiB
TypeScript
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)
|
|
}
|
|
})
|
|
}
|
|
}
|