+}))
+
+const COMMAND = '/opt/bun /slot/orcad.js --reclaim-stale-lock --data-root /home/dev/.orca'
+const DETAIL = `orcad holds the data root. If it is not running anywhere, run \`${COMMAND}\` on the machine it ran on.`
+const refusedStart: SshManagedServerStatus = {
+ kind: 'managed',
+ environmentId: 'env-1',
+ serving: { state: 'unverifiable', detail: DETAIL }
+}
+
+const eligible: OrcadManagedStaleLockReclaimResult = {
+ status: 'eligible',
+ lockPath: '/home/dev/.orca/orcad.lock',
+ message: 'eligible',
+ evidence: {
+ pid: 41,
+ host: 'box',
+ bootId: 'boot-old',
+ currentBootId: 'boot-new',
+ ageMinutes: 90,
+ nonce: 'n-approved'
+ }
+}
+const reclaimStaleLock = vi.fn(
+ async (args: { yes: boolean }): Promise =>
+ args.yes
+ ? { status: 'reclaimed', environmentId: 'env-1', message: 'cleared', lockPath: '/l' }
+ : eligible
+)
+const roots: Root[] = []
+beforeEach(() => {
+ reclaimStaleLock.mockClear()
+ Object.assign(window, { api: { runtimeEnvironments: { managedOrcad: { reclaimStaleLock } } } })
+})
+afterEach(() => {
+ for (const root of roots.splice(0)) {
+ act(() => root.unmount())
+ }
+ document.body.innerHTML = ''
+})
+
+async function render(onOpenChange = vi.fn(), onChanged = vi.fn()) {
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+ roots.push(root)
+ const show = (status: SshManagedServerStatus) =>
+ act(async () => {
+ root.render(
+
+ )
+ })
+ await show(refusedStart)
+ return Object.assign(container, { show })
+}
+
+function button(container: HTMLElement, text: string): HTMLButtonElement {
+ return [...container.querySelectorAll('button')].find((entry) => entry.textContent === text)!
+}
+
+describe('Recover server', () => {
+ it('is offered only when the slot’s own refusal named the command', () => {
+ expect(canRecoverSshHostServer(refusedStart)).toBe(true)
+ expect(
+ canRecoverSshHostServer({
+ kind: 'managed',
+ environmentId: 'env-1',
+ serving: { state: 'unverifiable', detail: 'orcad holds the data root.' }
+ })
+ ).toBe(false)
+ })
+
+ it('is not offered on the web client, which has no managed-server API', () => {
+ Object.assign(window, { api: { runtimeEnvironments: {} } })
+ expect(canRecoverSshHostServer(refusedStart)).toBe(false)
+ })
+
+ it('names the refusal’s command for a manual recovery', () => {
+ expect(staleLockReclaimCommandFrom(DETAIL)).toBe(COMMAND)
+ })
+
+ it('clears the lock only after the user rules out another machine', async () => {
+ const onOpenChange = vi.fn()
+ const onChanged = vi.fn()
+ const container = await render(onOpenChange, onChanged)
+ expect(reclaimStaleLock).toHaveBeenCalledWith({ selector: 'env-1', yes: false })
+ expect(container.textContent).toContain('boot-old')
+ const confirm = button(container, 'Clear lock and restart')
+ expect(confirm.disabled).toBe(true)
+ await act(async () => container.querySelector('[role="checkbox"]')!.click())
+ expect(confirm.disabled).toBe(false)
+ expect(reclaimStaleLock).toHaveBeenCalledTimes(1)
+ await act(async () => confirm.click())
+ expect(reclaimStaleLock).toHaveBeenLastCalledWith({
+ selector: 'env-1',
+ yes: true,
+ expectNonce: 'n-approved'
+ })
+ expect(onChanged).toHaveBeenCalled()
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+
+ // The reclaim clears the note that offered it, so the status no longer passes the gate.
+ it('stays open with its result once the status that offered it is gone', async () => {
+ reclaimStaleLock.mockResolvedValueOnce(eligible).mockResolvedValueOnce({
+ status: 'reclaimed',
+ environmentId: 'env-1',
+ message: 'cleared',
+ lockPath: '/l',
+ notRestarted: 'did not start'
+ })
+ const onOpenChange = vi.fn()
+ const container = await render(onOpenChange)
+ await container.show({ kind: 'managed', environmentId: 'env-1' })
+ expect(container.textContent).toContain('boot-old')
+ await act(async () => container.querySelector('[role="checkbox"]')!.click())
+ await act(async () => button(container, 'Clear lock and restart').click())
+ expect(container.textContent).toContain('did not start')
+ expect(onOpenChange).not.toHaveBeenCalled()
+ // The lock is gone: no stale evidence, no second confirmation, no manual recovery.
+ expect(container.textContent).not.toContain('boot-old')
+ expect(button(container, 'Clear lock and restart')).toBeUndefined()
+ expect(container.textContent).not.toContain('--reclaim-stale-lock')
+ expect(container.textContent).not.toContain('rm /')
+ expect(button(container, 'Close')).toBeDefined()
+ })
+
+ it('offers only a delete by hand when the host can’t run the check', async () => {
+ reclaimStaleLock.mockResolvedValueOnce({
+ status: 'unsupported',
+ message: 'no answer',
+ lockPath: '/home/dev/.orca/orcad.lock'
+ })
+ const container = await render()
+ expect(container.textContent).toContain('no answer')
+ expect(container.textContent).toContain('rm /home/dev/.orca/orcad.lock')
+ expect(container.textContent).not.toContain('--reclaim-stale-lock')
+ expect(button(container, 'Clear lock and restart')).toBeUndefined()
+ })
+
+ it.each([
+ {
+ status: 'refused',
+ message: 'still running',
+ lockPath: '/home/dev/.orca/orcad.lock'
+ } as const,
+ { status: 'no-answer', message: 'no answer understood' } as const,
+ { status: 'unsupported', message: 'too old to pin', reclaimWithoutPin: true } as const
+ ])('names the reclaim command, never a delete, for $status', async (result) => {
+ reclaimStaleLock.mockResolvedValueOnce(result)
+ const container = await render()
+ expect(container.textContent).toContain(COMMAND)
+ expect(container.textContent).not.toContain('rm /home')
+ })
+})
diff --git a/src/renderer/src/components/settings/SshHostRecoverServerAction.tsx b/src/renderer/src/components/settings/SshHostRecoverServerAction.tsx
new file mode 100644
index 00000000000..40ed0b53ac0
--- /dev/null
+++ b/src/renderer/src/components/settings/SshHostRecoverServerAction.tsx
@@ -0,0 +1,362 @@
+import { Loader2 } from 'lucide-react'
+import { useEffect, useState } from 'react'
+import type { OrcadManagedStaleLockReclaimResult } from '../../../../shared/orcad-managed-runtime'
+import {
+ ORCAD_RECLAIM_STALE_LOCK_FLAG,
+ type OrcadStaleLockEvidence
+} from '../../../../shared/orcad-stale-lock-reclaim'
+import type { SshManagedServerStatus } from '../../../../shared/ssh-types'
+import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orcad-api'
+import { quotePosixShellUnlessPlain } from '../../../../shared/shell-argument-quoting'
+import { useMountedRef } from '@/hooks/useMountedRef'
+import { formatUiRelativeTime } from '@/i18n/relative-time-format'
+import { translate } from '@/i18n/i18n'
+import { Button } from '../ui/button'
+import { Checkbox } from '../ui/checkbox'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle
+} from '../ui/dialog'
+import { Label } from '../ui/label'
+import { getManagedOrcadApi } from './use-managed-server-catalog'
+
+/** Offered only when the slot's own refusal named the command, so that build supports it. */
+export function canRecoverSshHostServer(status: SshManagedServerStatus | undefined): boolean {
+ return Boolean(
+ getManagedOrcadApi() &&
+ status?.kind === 'managed' &&
+ status.serving?.state === 'unverifiable' &&
+ status.serving.detail.includes(ORCAD_RECLAIM_STALE_LOCK_FLAG)
+ )
+}
+
+/** The command the refusal names, for the user to run on the host by hand. */
+export function staleLockReclaimCommandFrom(detail: string): string | null {
+ return (
+ detail
+ .match(/`([^`]*)`/g)
+ ?.find((quoted) => quoted.includes(ORCAD_RECLAIM_STALE_LOCK_FLAG))
+ ?.slice(1, -1) ?? null
+ )
+}
+
+/**
+ * Clears a managed server's instance lock that a start refused because another machine sharing
+ * the home may hold it (#32177). The host checks first; only the user can rule that machine out.
+ */
+export function SshHostRecoverServerDialog({
+ status,
+ open,
+ onOpenChange,
+ onChanged
+}: {
+ status: SshManagedServerStatus | undefined
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onChanged: () => void
+}): React.JSX.Element | null {
+ const api = getManagedOrcadApi()
+ if (!api || !open) {
+ return null
+ }
+ // Mounted per opening, so each one starts from a fresh check.
+ return (
+
+ )
+}
+
+function OpenRecoverServerDialog({
+ api,
+ status,
+ onOpenChange,
+ onChanged
+}: {
+ api: ManagedOrcadPreloadApi
+ status: SshManagedServerStatus | undefined
+ onOpenChange: (open: boolean) => void
+ onChanged: () => void
+}): React.JSX.Element | null {
+ // Frozen at opening: the reclaim itself clears the note that offered it, and a later status
+ // push must neither close the dialog nor hide the result the user is reading.
+ const [opened] = useState(() =>
+ status?.kind === 'managed'
+ ? { environmentId: status.environmentId, detail: status.serving?.detail ?? '' }
+ : null
+ )
+ const mountedRef = useMountedRef()
+ const [check, setCheck] = useState(null)
+ const [confirmed, setConfirmed] = useState(false)
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ if (!opened) {
+ return
+ }
+ api.reclaimStaleLock({ selector: opened.environmentId, yes: false }).then(
+ (next) => mountedRef.current && setCheck(next),
+ (cause: unknown) => mountedRef.current && setError(messageOf(cause))
+ )
+ }, [api, mountedRef, opened])
+
+ if (!opened) {
+ return null
+ }
+ const { environmentId, detail } = opened
+ const reclaim = async (expectNonce: string): Promise => {
+ setBusy(true)
+ setError(null)
+ try {
+ const result = await api.reclaimStaleLock({ selector: environmentId, yes: true, expectNonce })
+ if (!mountedRef.current) {
+ return
+ }
+ if (result.status === 'reclaimed') {
+ onChanged()
+ if (!result.notRestarted) {
+ onOpenChange(false)
+ return
+ }
+ }
+ // A cleared lock ends the flow: its evidence and the confirmation no longer apply.
+ setCheck(result)
+ } catch (cause) {
+ if (mountedRef.current) {
+ setError(messageOf(cause))
+ }
+ } finally {
+ if (mountedRef.current) {
+ setBusy(false)
+ }
+ }
+ }
+ const eligible = check?.status === 'eligible'
+ // The confirmation is pinned to the record the user saw; without its nonce there is none to pin.
+ const approvedNonce = eligible ? check.evidence?.nonce : undefined
+ return (
+
+ )
+}
+
+function EligibleLock({
+ evidence
+}: {
+ evidence: OrcadStaleLockEvidence | undefined
+}): React.JSX.Element {
+ return (
+
+
+ {translate(
+ 'auto.components.settings.recoverServer.eligible',
+ 'Nothing answers on this machine at its old address, but Orca can’t rule out another machine with the same name that shares this home folder.'
+ )}
+
+ {translate(
+ 'auto.components.settings.recoverServer.warning',
+ 'Only continue if no other machine sharing this home runs Orca for this profile.'
+ )}
+
+
+ )
+}
+
+function CheckOutcome({
+ check,
+ detail
+}: {
+ check: OrcadManagedStaleLockReclaimResult
+ detail: string
+}): React.JSX.Element {
+ if (check.status === 'reclaimed') {
+ // The lock is gone, so neither the reclaim command nor a delete by hand applies.
+ return (
+
+
+ {translate(
+ 'auto.components.settings.recoverServer.reclaimedNotStarted',
+ 'The lock was cleared, but Orca’s server did not start.'
+ )}
+
+ {translate(
+ 'auto.components.settings.recoverServer.none',
+ 'There is no lock to clear any more. Reconnect to start this host’s Orca server.'
+ )}
+
+ )
+ }
+ return (
+
+
{check.message}
+ {check.status === 'refused' || check.status === 'no-answer' ? (
+
+ ) : null}
+ {/* Its orcad's own reclaim is safer than a delete; only a host without one gets rm. */}
+ {check.status === 'unsupported' ? (
+ check.reclaimWithoutPin ? (
+
+ ) : (
+
+ )
+ ) : null}
+
+ {translate(
+ 'auto.components.settings.recoverServer.manualDelete',
+ 'Delete the lock file shown in Details once you are sure no other machine uses it.'
+ )}
+
+ )
+ }
+ return (
+
+
+ {translate(
+ 'auto.components.settings.recoverServer.manualDeleteCommand',
+ 'Delete the lock on the host by hand:'
+ )}
+
+
+
+ {translate(
+ 'auto.components.settings.recoverServer.manualDeleteWarning',
+ 'Only do this if no other machine sharing this home runs Orca for this profile.'
+ )}
+