feat(runtime): remove an agent's downloaded runtime only once nothing on the host can still run it (#29139)

* feat(runtime): remove an agent's downloaded runtime only once nothing on the host can still run it

managedRuntime.remove {agent} (host-admin, capability managed-runtime.remove.v1)
deletes the agent's releases inside the launch gate. Before anything is deleted the
gate aborts starts under way (chat starts and conversation copies), stops the
agent's loaded chats on this host, and proves every retained child gone: failed
chat starts and sign-in children whose close was never proven. Every close settles
before the gate releases; if any owner stays unproven, Remove fails and keeps the
files. Stopping chats by agent is now one generic host helper that sign-in shares.
Install refuses while a removal runs, Remove refuses while an install runs, and a
removal cannot be cancelled.

* fix(runtime): status says whether a copy is there to remove, damaged copies included

canRemove is set whenever Orca's copy is present, whole or damaged, so Settings offers Remove for a
damaged copy too. The failed-start retain is dropped: the launch gate already holds every child it
admitted until it exits.
This commit is contained in:
Brennan Benson
2026-10-18 01:15:11 -07:00
committed by GitHub
parent e770ec3c44
commit 6fd2172b3f
11 changed files with 188 additions and 31 deletions
+3 -2
View File
@@ -49,12 +49,13 @@ import {
scrubAntigravityAcpEnvironment
} from '../antigravity/antigravity-acp-environment'
import {
ANTIGRAVITY_LAUNCH_GATE,
answerAntigravityChatAuth,
antigravityChatAccount
} from '../antigravity/antigravity-chat-account'
import { readAntigravitySignInLine } from '../antigravity/antigravity-sign-in-url'
import type { AcpSignInWatch } from './acp-agent-connection'
import { AcpLaunchGate } from './acp-launch-gate'
import type { AcpLaunchGate } from './acp-launch-gate'
/** How an agent lists its models without a session: from its `initialize` answer (plus extension
* requests), from a listing command, or not at all. Never `authenticate` or `session/new`. */
@@ -288,7 +289,7 @@ export const ANTIGRAVITY_LAUNCH_SPEC = {
privateTempDirectory: true,
// `session/load` replays the whole conversation, which the chat already holds.
resumePreference: 'resume',
launchGate: new AcpLaunchGate('Antigravity')
launchGate: ANTIGRAVITY_LAUNCH_GATE
} satisfies AcpLaunchSpec
export const ACP_LAUNCH_SPECS: readonly AcpLaunchSpec[] = [
@@ -7,11 +7,14 @@ import type { AcpAgentConnection } from '../acp/acp-agent-connection'
import { ProviderAcquisitionStarts } from '../provider-process/provider-acquisition-starts'
import { RevisionedState } from '../runtime/revisioned-state'
import { antigravityBrowserHelperWorks } from './antigravity-browser-helper'
import { AcpLaunchGate } from '../acp/acp-launch-gate'
type SignInOwner = Pick<AcpAgentConnection, 'close' | 'onExit' | 'exited'>
const retained = new ProviderAcquisitionStarts<SignInOwner>()
const folder = hostFolderAccountBinding('GEMINI_HOME', 'antigravity-chat', ['antigravity-acp'])
export const ANTIGRAVITY_LAUNCH_GATE = new AcpLaunchGate('Antigravity')
/** A failure whose message is already the sentence a person reads. */
export class AntigravitySignInSentence extends Error {}
@@ -48,6 +51,7 @@ export async function closeAntigravitySignInOwner(
): Promise<void> {
if (!(await connection.close().catch(() => false)) && !connection.exited) {
retained.retainFailed(home, connection)
ANTIGRAVITY_LAUNCH_GATE.retain(connection)
throw new AntigravitySignInCloseUnprovenError()
}
}
@@ -48,6 +48,7 @@ async function rig(bodies: ('hang' | 'whole')[]) {
new ManagedRuntimeInstallation('agent', runtime, {
dataDirectory,
findExternal: async () => false,
fence: (work) => work(),
fetcher
})
const phase = async (installation: ManagedRuntimeInstallation) =>
@@ -124,7 +125,11 @@ it('repairs a damaged copy when Install is clicked again', async () => {
installation.install()
await vi.waitFor(async () => expect(await phase(installation)).toBe('succeeded'))
await writeFile(join(root, '1.0', 'server'), 'cut')
expect(await installation.status()).toMatchObject({ installedVersion: null, damaged: true })
expect(await installation.status()).toMatchObject({
installedVersion: null,
damaged: true,
canRemove: true
})
installation.install()
await vi.waitFor(async () => expect(await phase(installation)).toBe('succeeded'))
@@ -1,8 +1,10 @@
// Installs a managed runtime when the person asks, on the host that runs its chats. One instance per
// runtime owns every change to its releases: it reclaims old ones once, first, then installs one at a
// time. Publishing the release by rename is the commit point; a cancel after it changes nothing.
// Remove deletes the releases only inside the host's ownership fence, never beside an install.
import { randomUUID } from 'node:crypto'
import { rm } from 'node:fs/promises'
import type {
ManagedRuntimeInstallState,
ManagedRuntimeOperation
@@ -23,6 +25,8 @@ export type ManagedRuntimeInstallationDeps = {
dataDirectory: string
/** Whether a start would run a copy found outside Orca's releases (PATH, install folders). */
findExternal: () => Promise<boolean>
/** Runs `work` once nothing on this host runs the runtime; rejects without running it otherwise. */
fence: (work: () => Promise<void>) => Promise<void>
fetcher?: MainHttpClient['fetch']
}
@@ -83,6 +87,8 @@ export class ManagedRuntimeInstallation {
installedVersion: release?.version ?? null,
installedCurrent: release?.current ?? false,
...(pin && !release?.current && broken.includes(pin.version) && { damaged: true }),
// A damaged copy is removable too, though no launch runs it.
canRemove: release !== null || broken.length > 0,
source
}
}
@@ -96,6 +102,9 @@ export class ManagedRuntimeInstallation {
}
const current = this.state.get().value
const { running } = this
if (running && current?.kind === 'remove') {
throw new Error(`${label} is being removed. Try again after it finishes.`)
}
if (current && current.id === running?.id && !running.controller.signal.aborted) {
return current
}
@@ -113,12 +122,39 @@ export class ManagedRuntimeInstallation {
return operation
}
/** Joins a running removal; refused while an install, or its cleanup, still runs. */
remove(): ManagedRuntimeOperation {
const current = this.state.get().value
if (this.running && current?.kind === 'remove') {
return current
}
if (this.running) {
throw new Error(`${this.runtime.label} is installing. Try again after it finishes.`)
}
const run: Run = { id: randomUUID(), controller: new AbortController(), committed: false }
const operation: ManagedRuntimeOperation = {
id: run.id,
kind: 'remove',
phase: 'removing',
downloadedBytes: 0,
totalBytes: null
}
this.running = run
this.state.set(operation)
this.mutation = this.mutation.then(() => this.runRemove(run))
return operation
}
/** Resolves with the outcome once the work has unwound: `cancelled`, or `succeeded` when it had
* already committed. No install queues behind a running one, so `mutation` ends with it. */
async cancel(operationId: string): Promise<ManagedRuntimeOperation | null> {
if (this.state.get().value?.id !== operationId) {
const current = this.state.get().value
if (current?.id !== operationId) {
throw new Error(`That ${this.runtime.label} installation is no longer running.`)
}
if (current.kind === 'remove') {
throw new Error(`Removing ${this.runtime.label} cannot be cancelled.`)
}
const run = this.running
if (run?.id === operationId && !run.committed) {
run.controller.abort()
@@ -170,6 +206,18 @@ export class ManagedRuntimeInstallation {
}
}
private async runRemove(run: Run): Promise<void> {
try {
await this.deps.fence(() => rm(this.root, { recursive: true, force: true }))
this.publish(run, { phase: 'succeeded' })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
this.publish(run, { phase: 'failed', error: message })
} finally {
this.running = this.running === run ? null : this.running
}
}
private async reclaim(): Promise<void> {
const { release, unreadable } = resolveInstalledRuntimeRelease(this.root, this.runtime.pin())
if (unreadable) {
@@ -15,30 +15,13 @@ import {
startAntigravityChatSignIn,
subscribeAntigravityChatSignIn
} from '../../../antigravity/antigravity-chat-sign-in'
import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
import { bindStructuredAgentSessionStream } from './structured-agent-session-status-stream'
import { structuredAgentHostLaunchSources } from '../../structured-agent-launch-support'
/** Stops each running Antigravity chat on this host as a worktree teardown stops its chats; the
* chat stays, and its next send starts the agent again. */
async function stopLiveAntigravityChats(): Promise<void> {
const host = getStructuredAgentSessionHost()
if (!host) {
return
}
const ids = host.loadedSessionIds().filter((id) => host.sessionAgent(id) === 'antigravity')
// Every close settles before the caller's fence releases, even when one rejects.
const closes = await Promise.allSettled(ids.map((id) => host.close(id, 'evict')))
// A close that could not prove its child gone keeps it loaded.
const stillLoaded = host.loadedSessionIds().some((id) => ids.includes(id))
if (stillLoaded || closes.some((close) => close.status === 'rejected')) {
throw new Error('An Antigravity chat on this host could not be stopped. Try again.')
}
}
import { stopStructuredAgentChats } from '../../structured-agent-chat-stop'
const chatSignInHost = (runtime: OrcaRuntimeService) => ({
launchSources: () => structuredAgentHostLaunchSources('antigravity', runtime),
stopLiveChats: stopLiveAntigravityChats
stopLiveChats: () => stopStructuredAgentChats('antigravity', 'Antigravity')
})
/** The caller a sign-in belongs to, as its transport proved it; one it can't name gets none. */
@@ -1,8 +1,11 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it, vi } from 'vitest'
import { acpLaunchSpecFor } from '../../../acp/acp-launch-specs'
import type * as LaunchSpecs from '../../../acp/acp-launch-specs'
import { writeRuntimeReleaseReceipt } from '../../../node-runtime/managed-runtime-release'
import type { RpcContext } from '../core'
import { MANAGED_RUNTIME_INSTALL_METHODS } from './managed-runtime-install'
@@ -23,8 +26,26 @@ vi.mock('../../../network/http-client', () => ({
})
})
}))
// A host with one loaded chat, which a requested close stops.
const chats = vi.hoisted((): { loaded: string[]; closed: string[] } => ({ loaded: [], closed: [] }))
vi.mock(
'../../../native-chat/agent-session-wire/structured-agent-session-registry',
async (actual) => ({
...(await actual<object>()),
getStructuredAgentSessionHost: () => ({
loadedSessionIds: () => [...chats.loaded],
sessionAgent: () => 'opencode',
close: async (id: string) => {
chats.closed.push(id)
chats.loaded = chats.loaded.filter((loaded) => loaded !== id)
}
})
})
)
vi.mock('../../../acp/acp-launch-specs', async (importOriginal) => {
const actual = await importOriginal<typeof LaunchSpecs>()
const { AcpLaunchGate } = await import('../../../acp/acp-launch-gate')
const launchGate = new AcpLaunchGate('Grok')
const pin = {
version: '1.0',
url: 'https://example.test/grok-1.0.tar.gz',
@@ -37,12 +58,12 @@ vi.mock('../../../acp/acp-launch-specs', async (importOriginal) => {
...actual,
acpLaunchSpecFor: (agent: string) => {
const spec = actual.acpLaunchSpecFor(agent)
return spec && { ...spec, command: 'orca-rpc-managed-test', managedRuntime }
return spec && { ...spec, command: 'orca-rpc-managed-test', managedRuntime, launchGate }
}
}
})
const [subscribeMethod, installMethod, cancelMethod] = MANAGED_RUNTIME_INSTALL_METHODS
const [subscribeMethod, installMethod, cancelMethod, removeMethod] = MANAGED_RUNTIME_INSTALL_METHODS
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers read only the settings store and the stream registry.
const ctx = {
runtime: {
@@ -79,3 +100,51 @@ it('pushes a snapshot, then the install another client starts, until the transpo
expect(frames).toHaveLength(seen)
await rm(host.dataDirectory, { recursive: true, force: true })
})
it('removes the files only once every chat and unclosed child that may run them is stopped', async () => {
host.dataDirectory = await mkdtemp(join(tmpdir(), 'orca-runtime-remove-rpc-'))
const agent = 'opencode'
const spec = acpLaunchSpecFor(agent)
const pin = spec?.managedRuntime?.pin()
if (!spec?.launchGate || !pin) {
throw new Error('managed test spec missing')
}
const release = join(host.dataDirectory, 'rt', pin.version)
await mkdir(release, { recursive: true })
await writeFile(join(release, 'server'), 'server', { mode: 0o755 })
await writeRuntimeReleaseReceipt(release, pin)
const transport = new AbortController()
const frames: unknown[] = []
await subscribeMethod.handler({ agent }, { ...ctx, signal: transport.signal }, (frame) =>
frames.push(frame)
)
const removed = async () => {
const { operation } = await removeMethod.handler({ agent }, ctx)
await vi.waitFor(() =>
expect(frames.at(-1)).toMatchObject({
operation: { id: operation?.id, phase: expect.stringMatching(/ed$/) }
})
)
return frames.at(-1)
}
// A sign-in child whose close was never proven still holds the files.
const child = { close: vi.fn(async () => false), onExit: () => {} }
spec.launchGate.retain(child)
chats.loaded = ['chat-1']
expect(await removed()).toMatchObject({
installedVersion: pin.version,
operation: { kind: 'remove', phase: 'failed', error: expect.stringContaining('stopped') }
})
child.close.mockResolvedValue(true)
chats.loaded = ['chat-2']
expect(await removed()).toMatchObject({
installedVersion: null,
source: null,
operation: { kind: 'remove', phase: 'succeeded' }
})
expect(chats.closed).toEqual(['chat-1', 'chat-2'])
expect(existsSync(join(host.dataDirectory, 'rt'))).toBe(false)
transport.abort()
await rm(host.dataDirectory, { recursive: true, force: true })
})
@@ -1,6 +1,7 @@
// The runtime Orca installs for an agent, on the host that receives the call: the one that runs
// that agent's chats. Needs no workspace. Negotiated by `managed-runtime.install.v1`. Status is
// pushed: a snapshot on subscribe, then one per state revision.
// that agent's chats. Needs no workspace. Negotiated by `managed-runtime.install.v1`; Remove by
// `managed-runtime.remove.v1`. Status is pushed: a snapshot on subscribe, then one per state
// revision.
import type { OrcaRuntimeService } from '../../orca-runtime'
import {
@@ -12,6 +13,7 @@ import { resolveAcpLaunchCommand } from '../../../acp/acp-structured-launch-reso
import { ManagedRuntimeInstallation } from '../../../node-runtime/managed-runtime-installation'
import { isAgentSessionPreSpawnError } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter'
import { getProfileUserDataPath } from '../../../orca-profiles/profile-storage-paths'
import { stopStructuredAgentChats } from '../../structured-agent-chat-stop'
import { resolveHostStructuredAgentLaunchEnvironment } from '../../structured-agent-launch-support'
import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path'
import { defineMethod, defineStreamingMethod } from '../core'
@@ -47,12 +49,20 @@ function installationFor(agent: string, runtime: OrcaRuntimeService): ManagedRun
throw error
}
}
const { launchGate } = spec
const managedRuntime = spec.managedRuntime
// Chats, starts, copies, sign-ins and children not proven gone all hold the gate's fence.
const fence = (work: () => Promise<void>): Promise<void> =>
launchGate
? launchGate.run(() => stopStructuredAgentChats(agent, managedRuntime.label), work)
: Promise.reject(new Error(`${managedRuntime.label} cannot be removed on this host.`))
const installation =
installations.get(agent) ??
// The chat host's own state root, where its launches look.
new ManagedRuntimeInstallation(agent, spec.managedRuntime, {
new ManagedRuntimeInstallation(agent, managedRuntime, {
dataDirectory,
findExternal
findExternal,
fence
})
installations.set(agent, installation)
return installation
@@ -122,5 +132,15 @@ export const MANAGED_RUNTIME_INSTALL_METHODS = [
// The outcome once the run has unwound: `cancelled`, or `succeeded` if it had committed.
return await installationFor(agent, runtime).cancel(operationId)
}
}),
defineMethod({
name: 'managedRuntime.remove',
permission: 'host-admin',
params: ManagedRuntimeAgentParams,
handler: async ({ agent }, { runtime }) => {
const installation = installationFor(agent, runtime)
installation.remove()
return installation.status()
}
})
] as const
@@ -0,0 +1,18 @@
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
/** Stops each running chat of `agent` on this host as a worktree teardown stops its chats; the chat
* stays, and its next send starts the agent again. Rejects unless every child is proven gone. */
export async function stopStructuredAgentChats(agent: string, label: string): Promise<void> {
const host = getStructuredAgentSessionHost()
if (!host) {
return
}
const ids = host.loadedSessionIds().filter((id) => host.sessionAgent(id) === agent)
// Every close settles before the caller's fence releases, even when one rejects.
const closes = await Promise.allSettled(ids.map((id) => host.close(id, 'evict')))
// A close that could not prove its child gone keeps it loaded.
const stillLoaded = host.loadedSessionIds().some((id) => ids.includes(id))
if (stillLoaded || closes.some((close) => close.status === 'rejected')) {
throw new Error(`Not every ${label} chat on this host could be stopped. Try again.`)
}
}
+4
View File
@@ -2,6 +2,8 @@
// The host answers managedRuntime.subscribe, managedRuntime.install and managedRuntime.cancel.
export const MANAGED_RUNTIME_INSTALL_RUNTIME_CAPABILITY = 'managed-runtime.install.v1' as const
// Why separate: a host that installs may predate managedRuntime.remove.
export const MANAGED_RUNTIME_REMOVE_RUNTIME_CAPABILITY = 'managed-runtime.remove.v1' as const
export type ManagedRuntimeOperation = {
id: string
@@ -34,6 +36,8 @@ export type ManagedRuntimeInstallState = {
installedCurrent: boolean
/** The pinned release's folder is there but not whole; Install repairs it. */
damaged?: boolean
/** Orca's copy is there to remove, whole or damaged; absent from a host without Remove. */
canRemove?: boolean
/** What a chat start runs now: Orca's release, a copy found outside it, or nothing. */
source: 'managed' | 'external' | null
/** The latest operation; an ended one stays readable until the next starts. */
+5 -1
View File
@@ -63,7 +63,10 @@ export * from './agent-session-resume-runtime-capabilities'
import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from './agent-session-background-task-child-views-capability'
import { AGENT_SESSION_STOP_RUNTIME_CAPABILITIES } from './agent-session-stop-capabilities'
import { AGENT_SESSION_CREATE_RUNTIME_CAPABILITIES } from './agent-session-create-capabilities'
import { MANAGED_RUNTIME_INSTALL_RUNTIME_CAPABILITY } from './managed-runtime-install'
import {
MANAGED_RUNTIME_INSTALL_RUNTIME_CAPABILITY,
MANAGED_RUNTIME_REMOVE_RUNTIME_CAPABILITY
} from './managed-runtime-install'
import { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update'
import { MOBILE_RELAY_RUNTIME_CAPABILITIES } from './mobile-desktop-relay-contract'
import { SKILL_RUNTIME_CAPABILITIES } from './skill-install-capability'
@@ -232,6 +235,7 @@ export const RUNTIME_CAPABILITIES = [
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
REMOTE_SERVER_UPDATE_CAPABILITY,
MANAGED_RUNTIME_INSTALL_RUNTIME_CAPABILITY,
MANAGED_RUNTIME_REMOVE_RUNTIME_CAPABILITY,
AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY,
AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
AGENT_SESSION_KEYBOARD_RUNTIME_CAPABILITY,
+1
View File
@@ -1104,6 +1104,7 @@ export const RPC_PARAMS_BY_METHOD = {
'linear.updateIssue': IssueUpdateOfLinearParams,
'managedRuntime.cancel': ManagedRuntimeCancelParams,
'managedRuntime.install': ManagedRuntimeAgentParams,
'managedRuntime.remove': ManagedRuntimeAgentParams,
'managedRuntime.subscribe': ManagedRuntimeAgentParams,
'managedServer.cancelStop': ManagedServerSelector,
'managedServer.forget': ManagedServerSelector,