diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index f1760340f44..60c27cb9011 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -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[] = [ diff --git a/src/main/antigravity/antigravity-chat-account.ts b/src/main/antigravity/antigravity-chat-account.ts index 8181131ff77..85d655e0070 100644 --- a/src/main/antigravity/antigravity-chat-account.ts +++ b/src/main/antigravity/antigravity-chat-account.ts @@ -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 const retained = new ProviderAcquisitionStarts() 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 { if (!(await connection.close().catch(() => false)) && !connection.exited) { retained.retainFailed(home, connection) + ANTIGRAVITY_LAUNCH_GATE.retain(connection) throw new AntigravitySignInCloseUnprovenError() } } diff --git a/src/main/node-runtime/managed-runtime-installation.test.ts b/src/main/node-runtime/managed-runtime-installation.test.ts index 5afea1e8d5e..33f2bb8351a 100644 --- a/src/main/node-runtime/managed-runtime-installation.test.ts +++ b/src/main/node-runtime/managed-runtime-installation.test.ts @@ -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')) diff --git a/src/main/node-runtime/managed-runtime-installation.ts b/src/main/node-runtime/managed-runtime-installation.ts index 7d0fbf32fd8..44f666e862e 100644 --- a/src/main/node-runtime/managed-runtime-installation.ts +++ b/src/main/node-runtime/managed-runtime-installation.ts @@ -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 + /** Runs `work` once nothing on this host runs the runtime; rejects without running it otherwise. */ + fence: (work: () => Promise) => Promise 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 { - 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 { + 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 { const { release, unreadable } = resolveInstalledRuntimeRelease(this.root, this.runtime.pin()) if (unreadable) { diff --git a/src/main/runtime/rpc/methods/antigravity-accounts.ts b/src/main/runtime/rpc/methods/antigravity-accounts.ts index 92ad05247bf..b8a3f3a9bc7 100644 --- a/src/main/runtime/rpc/methods/antigravity-accounts.ts +++ b/src/main/runtime/rpc/methods/antigravity-accounts.ts @@ -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 { - 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. */ diff --git a/src/main/runtime/rpc/methods/managed-runtime-install.test.ts b/src/main/runtime/rpc/methods/managed-runtime-install.test.ts index 3e732922525..8b59404b944 100644 --- a/src/main/runtime/rpc/methods/managed-runtime-install.test.ts +++ b/src/main/runtime/rpc/methods/managed-runtime-install.test.ts @@ -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()), + 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() + 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 }) +}) diff --git a/src/main/runtime/rpc/methods/managed-runtime-install.ts b/src/main/runtime/rpc/methods/managed-runtime-install.ts index dde5d195757..86c78bb574f 100644 --- a/src/main/runtime/rpc/methods/managed-runtime-install.ts +++ b/src/main/runtime/rpc/methods/managed-runtime-install.ts @@ -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): Promise => + 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 diff --git a/src/main/runtime/structured-agent-chat-stop.ts b/src/main/runtime/structured-agent-chat-stop.ts new file mode 100644 index 00000000000..f6929dfe09a --- /dev/null +++ b/src/main/runtime/structured-agent-chat-stop.ts @@ -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 { + 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.`) + } +} diff --git a/src/shared/managed-runtime-install.ts b/src/shared/managed-runtime-install.ts index 25f7b5d48c1..0c0d9af27c3 100644 --- a/src/shared/managed-runtime-install.ts +++ b/src/shared/managed-runtime-install.ts @@ -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. */ diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 0688fd0147a..ac2679f7d79 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -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, diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 5a1c7378c56..fc72eb49418 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -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,