From e70193040fb43d64d78c35ded764e2e41b1c52ee Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 13 Nov 2026 05:39:47 -0800 Subject: [PATCH] fix(cloud): EP-8 Delete works when this computer can't reach the sandbox (PR 1 of 2) (#683) * fix(cloud): EP-8 Delete of a running sandbox this computer can't reach (revoked pairing) goes through Orca Cloud with Force delete, never waits silently * fix(cloud): EP-8 refusal copy says sandbox and this computer (design-squad) --- .../cloud-delete-preflight.test.ts | 36 +++++++++++++++ .../hosted-sandbox/cloud-delete-preflight.ts | 17 +++++-- .../hosted-sandbox/cloud-server-probe.test.ts | 30 +++++++++++++ src/main/hosted-sandbox/cloud-server-probe.ts | 44 +++++++++++++++++++ src/main/ipc/ephemeral-vm-runtime-handlers.ts | 4 +- src/preload/api/ephemeral-vm-api.ts | 2 +- .../teardown/cloud-sandbox-delete-guard.ts | 23 ++++++++-- .../teardown/cloud-workspace-delete.test.ts | 32 ++++++++++++++ src/shared/worktree/removal.ts | 8 ++++ 9 files changed, 187 insertions(+), 9 deletions(-) create mode 100644 src/main/hosted-sandbox/cloud-server-probe.test.ts create mode 100644 src/main/hosted-sandbox/cloud-server-probe.ts diff --git a/src/main/hosted-sandbox/cloud-delete-preflight.test.ts b/src/main/hosted-sandbox/cloud-delete-preflight.test.ts index b641173578d..f067788cc6f 100644 --- a/src/main/hosted-sandbox/cloud-delete-preflight.test.ts +++ b/src/main/hosted-sandbox/cloud-delete-preflight.test.ts @@ -62,4 +62,40 @@ describe('preflightCloudWorkspaceDelete', () => { gone: true }) }) + + it("EP-8: a running sandbox whose server refuses or doesn't answer this computer is unreachable", async () => { + expect( + await preflightCloudWorkspaceDelete('sbx_1', answering('running'), async () => 'rejected') + ).toEqual({ ok: true, unreachable: 'rejected' }) + expect( + await preflightCloudWorkspaceDelete('sbx_1', answering('running'), async () => 'no_answer') + ).toEqual({ ok: true, unreachable: 'no_answer' }) + expect( + await preflightCloudWorkspaceDelete('sbx_1', answering('running'), async () => { + throw new Error('boom') + }) + ).toEqual({ ok: true, unreachable: 'no_answer' }) + expect( + await preflightCloudWorkspaceDelete('sbx_1', answering('running'), async () => 'ok') + ).toEqual({ + ok: true + }) + }) + + it('EP-8: never probes an asleep or destroyed sandbox', async () => { + let probed = 0 + const probe = async (): Promise<'ok'> => { + probed += 1 + return 'ok' + } + expect(await preflightCloudWorkspaceDelete('sbx_1', answering('suspended'), probe)).toEqual({ + ok: true, + asleep: true + }) + expect(await preflightCloudWorkspaceDelete('sbx_1', answering('destroyed'), probe)).toEqual({ + ok: true, + gone: true + }) + expect(probed).toBe(0) + }) }) diff --git a/src/main/hosted-sandbox/cloud-delete-preflight.ts b/src/main/hosted-sandbox/cloud-delete-preflight.ts index fb36583f13b..4ceec469cb7 100644 --- a/src/main/hosted-sandbox/cloud-delete-preflight.ts +++ b/src/main/hosted-sandbox/cloud-delete-preflight.ts @@ -8,11 +8,16 @@ import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-p /** * `asleep`: no running server to remove its checkout; destroying the sandbox takes the disk. * `gone`: Orca Cloud already destroyed it (or it never started); only the row is left. + * `unreachable` (EP-8): running, but this computer can't reach its server (its pairing was revoked, + * e.g. by an owner change, or it doesn't answer). Like asleep: Delete goes through Orca Cloud. */ export type CloudDeletePreflight = - | { ok: true; asleep?: boolean; gone?: boolean } + | { ok: true; asleep?: boolean; gone?: boolean; unreachable?: 'rejected' | 'no_answer' } | ({ ok: false } & EphemeralVmFailure) +/** Can this computer reach the workspace's server right now? A short probe, never a wake. */ +export type CloudServerProbe = () => Promise<'ok' | 'rejected' | 'no_answer'> + const ASLEEP_STATUSES = new Set(['suspending', 'suspended', 'archived']) const GONE_STATUSES = new Set(['destroying', 'destroyed', 'failed']) @@ -25,7 +30,8 @@ export function cloudSandboxIdForEnvironment( export async function preflightCloudWorkspaceDelete( sandboxId: string | undefined, - client: Pick | null + client: Pick | null, + probe?: CloudServerProbe ): Promise { if (!sandboxId) { // Not an Orca Cloud workspace (or its sandbox is already cleaned up): nothing to prove. @@ -39,7 +45,12 @@ export async function preflightCloudWorkspaceDelete( if (GONE_STATUSES.has(view.status)) { return { ok: true, gone: true } } - return ASLEEP_STATUSES.has(view.status) ? { ok: true, asleep: true } : { ok: true } + if (ASLEEP_STATUSES.has(view.status)) { + return { ok: true, asleep: true } + } + // Running on Orca Cloud: a Delete through a server this computer can't reach would wait forever. + const reach = probe ? await probe().catch(() => 'no_answer' as const) : 'ok' + return reach === 'ok' ? { ok: true } : { ok: true, unreachable: reach } } catch (error) { if (isEnvironmentProviderError(error, 'not_found')) { return { ok: false, ...ephemeralVmFailure('cloudDeleteNotConfirmed') } diff --git a/src/main/hosted-sandbox/cloud-server-probe.test.ts b/src/main/hosted-sandbox/cloud-server-probe.test.ts new file mode 100644 index 00000000000..b4e53d987e2 --- /dev/null +++ b/src/main/hosted-sandbox/cloud-server-probe.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { classifyProbeFailure, probeCloudServer } from './cloud-server-probe' + +describe('probeCloudServer (EP-8)', () => { + it('tells a refused pairing apart from a server that does not answer', () => { + expect(classifyProbeFailure('unauthorized', '')).toBe('rejected') + expect(classifyProbeFailure('device_revoked', '')).toBe('rejected') + expect(classifyProbeFailure(undefined, 'Remote Orca runtime rejected the pairing token.')).toBe( + 'rejected' + ) + expect(classifyProbeFailure('timeout', 'Request timed out')).toBe('no_answer') + }) + + it('asks the server for its status, briefly', async () => { + const calls: unknown[][] = [] + const ok = async (...args: unknown[]) => { + calls.push(args) + return { id: 'status.get', ok: true as const, result: {}, _meta: { runtimeId: 'r' } } + } + expect(await probeCloudServer('/data', 'env-1', ok)).toBe('ok') + expect(calls[0]?.slice(1, 5)).toEqual(['env-1', 'status.get', undefined, 5_000]) + const refused = async () => { + throw Object.assign(new Error('Remote Orca runtime rejected the pairing token.'), { + code: 'unauthorized' + }) + } + expect(await probeCloudServer('/data', 'env-1', refused)).toBe('rejected') + expect(await probeCloudServer('/data', '', ok)).toBe('no_answer') + }) +}) diff --git a/src/main/hosted-sandbox/cloud-server-probe.ts b/src/main/hosted-sandbox/cloud-server-probe.ts new file mode 100644 index 00000000000..b34d7cfd7f4 --- /dev/null +++ b/src/main/hosted-sandbox/cloud-server-probe.ts @@ -0,0 +1,44 @@ +// EP-8: can this computer reach an Orca Cloud workspace's server right now? A short status call over +// its saved pairing. A refused pairing (revoked by an owner change or a removal) is told apart from a +// server that just doesn't answer, so the caller can say which. +import { callRuntimeEnvironment } from '../ipc/runtime-environments/runtime-environment-transport-routing' + +const PROBE_TIMEOUT_MS = 5_000 +const REJECTED_CODES = new Set(['unauthorized', 'device_revoked', 'forbidden']) + +export function classifyProbeFailure( + code: string | undefined, + message: string +): 'rejected' | 'no_answer' { + return (code && REJECTED_CODES.has(code)) || /rejected the pairing token/i.test(message) + ? 'rejected' + : 'no_answer' +} + +export async function probeCloudServer( + userDataPath: string, + environmentId: string, + call: typeof callRuntimeEnvironment = callRuntimeEnvironment +): Promise<'ok' | 'rejected' | 'no_answer'> { + if (!environmentId) { + return 'no_answer' + } + try { + const response = await call( + userDataPath, + environmentId, + 'status.get', + undefined, + PROBE_TIMEOUT_MS + ) + return response.ok + ? 'ok' + : classifyProbeFailure(response.error?.code, response.error?.message ?? '') + } catch (error) { + const code = + error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' + ? error.code + : undefined + return classifyProbeFailure(code, error instanceof Error ? error.message : String(error)) + } +} diff --git a/src/main/ipc/ephemeral-vm-runtime-handlers.ts b/src/main/ipc/ephemeral-vm-runtime-handlers.ts index bd62a905402..e12c8e3eb85 100644 --- a/src/main/ipc/ephemeral-vm-runtime-handlers.ts +++ b/src/main/ipc/ephemeral-vm-runtime-handlers.ts @@ -28,6 +28,7 @@ import { preflightCloudWorkspaceDelete, type CloudDeletePreflight } from '../hosted-sandbox/cloud-delete-preflight' +import { probeCloudServer } from '../hosted-sandbox/cloud-server-probe' import { getHostedSandboxControlPlaneClient } from '../hosted-sandbox/hosted-sandbox-install' import { parsePendingVmCheckouts, @@ -157,7 +158,8 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { app.getPath('userData'), String(args?.runtimeEnvironmentId ?? '') ), - getHostedSandboxControlPlaneClient() + getHostedSandboxControlPlaneClient(), + () => probeCloudServer(app.getPath('userData'), String(args?.runtimeEnvironmentId ?? '')) ) ) diff --git a/src/preload/api/ephemeral-vm-api.ts b/src/preload/api/ephemeral-vm-api.ts index bdf4cda8b16..69f3bcdcafa 100644 --- a/src/preload/api/ephemeral-vm-api.ts +++ b/src/preload/api/ephemeral-vm-api.ts @@ -132,7 +132,7 @@ export type EphemeralVmApi = { preflightCloudDelete?: (args: { runtimeEnvironmentId: string }) => Promise< - | { ok: true; asleep?: boolean; gone?: boolean } + | { ok: true; asleep?: boolean; gone?: boolean; unreachable?: 'rejected' | 'no_answer' } | { ok: false; error: string; errorCode?: string } > stopCleanup: (args: { runtimeId: string }) => Promise diff --git a/src/renderer/src/store/slices/worktrees/teardown/cloud-sandbox-delete-guard.ts b/src/renderer/src/store/slices/worktrees/teardown/cloud-sandbox-delete-guard.ts index f53c315c9e6..6224798bbfd 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/cloud-sandbox-delete-guard.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/cloud-sandbox-delete-guard.ts @@ -1,9 +1,15 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' -import { ephemeralVmCleanupErrorText, ephemeralVmErrorText } from '@/runtime/ephemeral-vm/ephemeral-vm-error-copy' +import { + ephemeralVmCleanupErrorText, + ephemeralVmErrorText +} from '@/runtime/ephemeral-vm/ephemeral-vm-error-copy' import type { EphemeralVmRuntimeRecord } from '../../../../../../shared/ephemeral-vm-runtimes' import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target' -import { CLOUD_ASLEEP_DELETE_REFUSAL } from '../../../../../../shared/worktree/removal' +import { + CLOUD_ASLEEP_DELETE_REFUSAL, + CLOUD_UNREACHABLE_DELETE_REFUSAL +} from '../../../../../../shared/worktree/removal' import type { RemoveWorktreeOptions } from './worktree-removal-options' import type { WorktreeSliceGet } from '../listing/worktree-slice-types' import type { ExecutionHostId } from '../../../../../../shared/execution-host' @@ -54,7 +60,10 @@ export async function cloudSandboxSkipsServer( return false } const result = await preflightCloudDelete(runtimeEnvironmentId)?.catch(() => null) - return result?.ok === true && (result.asleep === true || result.gone === true) + return ( + result?.ok === true && + (result.asleep === true || result.gone === true || result.unreachable !== undefined) + ) } /** @@ -108,12 +117,18 @@ export async function prepareCloudSandboxDelete(args: { if (!result.ok) { throw new Error(ephemeralVmErrorText(result)) } - if (!result.asleep && !result.gone) { + // EP-8: running, but its server won't answer this computer (a revoked pairing): asking it would + // wait forever, so Delete goes through Orca Cloud like an asleep one, and says so. + const unreachable = result.unreachable !== undefined + if (!result.asleep && !result.gone && !unreachable) { return args.options } if (result.asleep && !args.force) { throw new Error(CLOUD_ASLEEP_DELETE_REFUSAL) } + if (unreachable && !args.force) { + throw new Error(CLOUD_UNREACHABLE_DELETE_REFUSAL) + } // forget-local skips the transport's own pre-send checks, so run them here. args.assertBeforeDispatch() return { ...args.options, mode: 'forget-local' } diff --git a/src/renderer/src/store/slices/worktrees/teardown/cloud-workspace-delete.test.ts b/src/renderer/src/store/slices/worktrees/teardown/cloud-workspace-delete.test.ts index a7ab90edba6..50adab0cdfb 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/cloud-workspace-delete.test.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/cloud-workspace-delete.test.ts @@ -122,4 +122,36 @@ describe('Orca Cloud workspace delete', () => { expect(calledMethods()).not.toContain('worktree.rm') expect(mockApi.worktrees.forgetLocal).toHaveBeenCalled() }) + + it('EP-8: a running sandbox this computer cannot reach asks for Force delete, never waits on it', async () => { + const { store, wt } = seedCloudWorkspace() + Object.assign(mockApi.ephemeralVm, { + preflightCloudDelete: vi.fn().mockResolvedValue({ ok: true, unreachable: 'rejected' }) + }) + + const result = await store.getState().removeWorktree({ id: wt.id, executionHostId: null }) + + expect(result.ok).toBe(false) + // Nothing went to the server whose pairing was refused (it would never answer). + expect(calledMethods()).toEqual([]) + expect(mockApi.worktrees.forgetLocal).not.toHaveBeenCalled() + expect(store.getState().deleteStateByWorktreeId[wt.id]?.canForceDelete).toBe(true) + }) + + it('EP-8: Force delete of an unreachable sandbox destroys it through Orca Cloud', async () => { + const { store, wt } = seedCloudWorkspace() + Object.assign(mockApi.ephemeralVm, { + preflightCloudDelete: vi.fn().mockResolvedValue({ ok: true, unreachable: 'no_answer' }) + }) + mockApi.ephemeralVm.listRuntimes.mockImplementation(async () => [ + { id: 'runtime-cloud', workspaceId: wt.id, cleanupStatus: 'not_started' } + ]) + + const result = await store.getState().removeWorktree({ id: wt.id, executionHostId: null }, true) + + expect(result).toEqual({ ok: true }) + expect(calledMethods()).toEqual([]) + expect(mockApi.worktrees.forgetLocal).toHaveBeenCalled() + expect(mockApi.ephemeralVm.cleanup).toHaveBeenCalledWith({ runtimeId: 'runtime-cloud' }) + }) }) diff --git a/src/shared/worktree/removal.ts b/src/shared/worktree/removal.ts index 62ddb24ab7d..62c3479108d 100644 --- a/src/shared/worktree/removal.ts +++ b/src/shared/worktree/removal.ts @@ -121,6 +121,13 @@ const FORMATTED_DIRTY_WORKTREE_REMOVAL_PATTERN = export const CLOUD_ASLEEP_DELETE_REFUSAL = 'This workspace is asleep, so Orca can’t check it for unsaved changes. Use Force delete to delete it and its disk without waking it.' +/** + * EP-8: a running Orca Cloud sandbox this computer can't reach (its pairing was revoked, or its server + * doesn't answer) can't be checked for unsaved work either; Force delete deletes it through Orca Cloud. + */ +export const CLOUD_UNREACHABLE_DELETE_REFUSAL = + 'Orca can’t reach this workspace’s sandbox from this computer, so it can’t check it for unsaved changes. Use Force delete to delete it and its disk through Orca Cloud.' + export function classifyWorktreeForceDeleteReason( error: string, force = false, @@ -154,6 +161,7 @@ export function classifyWorktreeForceDeleteReason( } if ( error === CLOUD_ASLEEP_DELETE_REFUSAL || + error === CLOUD_UNREACHABLE_DELETE_REFUSAL || error.includes('Worktree has uncommitted or untracked changes') || error.includes('contains modified or untracked files') || FORMATTED_DIRTY_WORKTREE_REMOVAL_PATTERN.test(error)