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
@@ -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) {