From dc7f7bced4a729db9bc3c76ee8d1fe1061758a10 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 11 Oct 2026 00:59:07 -0400 Subject: [PATCH] revert(mobile): drop server sleep and wake from the phone for now The workspace-layout refactor deletes the notifier path these used; sleep and wake on a server come back later as server-side layout.sleep / layout.wake. A server row's Sleep behaves exactly as on feat/mobile-remote-workspaces. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../src/host-screen/host-screen-overlays.tsx | 2 +- .../host-server-workspaces-wiring.test.ts | 37 +---- .../host-screen/use-host-worktree-actions.ts | 15 +- .../host-screen/use-host-worktree-catalog.ts | 21 +-- .../session/use-mobile-session-foundation.ts | 6 +- .../use-mobile-session-startup.test.tsx | 113 --------------- .../src/session/use-mobile-session-startup.ts | 33 +---- mobile/src/transport/use-workspace-client.ts | 15 +- .../worktree/server-workspace-operations.ts | 27 ---- .../worktree/worktree-host-row-identity.ts | 18 +-- .../orca-runtime-activate-managed-worktree.ts | 44 +++--- .../orca-runtime-list-managed-worktrees.ts | 9 +- .../mobile-relay-host-sleep-wake.spec.ts | 134 ------------------ src/main/runtime/orca-runtime.test.ts | 1 - .../runtime/rpc/methods/mobile-relay-hosts.ts | 30 +--- .../runtime-rpc-mobile-method-allowlist.ts | 2 - ...ntime-rpc-mobile-paired-desktop-methods.ts | 5 +- src/shared/mobile-relay-hosts-contract.ts | 11 -- .../rpc-params-catalog.generated.ts | 7 +- .../e2e/paired-mobile-desktop-mirror.spec.ts | 36 +---- 20 files changed, 51 insertions(+), 515 deletions(-) delete mode 100644 mobile/src/session/use-mobile-session-startup.test.tsx delete mode 100644 src/main/runtime/orca-runtime-tests/mobile-relay-host-sleep-wake.spec.ts diff --git a/mobile/src/host-screen/host-screen-overlays.tsx b/mobile/src/host-screen/host-screen-overlays.tsx index 987ca18b645..153c90cc3ce 100644 --- a/mobile/src/host-screen/host-screen-overlays.tsx +++ b/mobile/src/host-screen/host-screen-overlays.tsx @@ -189,7 +189,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr label: 'Sleep', icon: Moon, onPress: () => { - actions.sleepWorktree(actionTarget) + actions.putWorktreeToSleep(actionTarget) state.setActionTarget(null) } }, diff --git a/mobile/src/host-screen/host-server-workspaces-wiring.test.ts b/mobile/src/host-screen/host-server-workspaces-wiring.test.ts index dad22f4c3d2..2bac7fac607 100644 --- a/mobile/src/host-screen/host-server-workspaces-wiring.test.ts +++ b/mobile/src/host-screen/host-server-workspaces-wiring.test.ts @@ -176,7 +176,9 @@ async function mountScreen(first: FakeSession, hostCapabilities: string[]): Prom }), sleep: (worktreeId) => act(() => { - held.actions?.sleepWorktree(held.rows.find((entry) => entry.worktreeId === worktreeId)!) + held.actions?.putWorktreeToSleep( + held.rows.find((entry) => entry.worktreeId === worktreeId)! + ) }), slept: () => held.state?.sleptIds, serverWorkspaces: () => held.state?.serverWorkspaces, @@ -319,41 +321,12 @@ describe("a desktop's server workspaces on the phone", () => { expect(screen.rows().find((entry) => entry.worktreeId === 'runtime:vm-wt')?.isPinned).toBe(true) }) - it('sleeps a server workspace through the desktop, whose renderer runs the sleep', async () => { + it('sleeps a desktop workspace on the desktop and shows it slept at once', async () => { const client = desktop() const screen = await mountScreen(client, RELAYS) - screen.sleep('runtime:vm-wt') screen.sleep('mac-wt') - const sleeps = client.sendRequest.mock.calls.filter(([method]) => method.includes('leep')) - expect(sleeps.map(([method, params]) => [method, params])).toEqual([ - ['mobileRelay.hosts.sleepWorktree', { hostId: 'runtime:vm', worktreeId: 'runtime:vm-wt' }], - ['worktree.sleep', { worktree: 'id:mac-wt' }] - ]) - }) - - it('shows a server workspace slept until the server itself reports it so', async () => { - let live = 2 - const client = desktop({ - hostWorktrees: (hostId) => ({ - worktrees: [{ ...row(`${hostId}-wt`, hostId), liveTerminalCount: live }], - stale: false, - fetchedAt: 1 - }) - }) - const screen = await mountScreen(client, RELAYS) - const serverRow = () => screen.rows().find((entry) => entry.worktreeId === 'runtime:vm-wt') - expect(serverRow()?.liveTerminalCount).toBe(2) - - screen.sleep('runtime:vm-wt') - await screen.poll() - // The desktop's own list never carries the server's row, so it cannot confirm or undo the sleep. - expect(serverRow()?.liveTerminalCount).toBe(0) + expect(client.sendRequest).toHaveBeenCalledWith('worktree.sleep', { worktree: 'id:mac-wt' }) expect(screen.slept()?.size).toBe(1) - - live = 0 - await screen.poll() - expect(screen.slept()?.size).toBe(0) - expect(serverRow()?.liveTerminalCount).toBe(0) }) it('reads a replaced client\u2019s servers at once, and keeps an unchanged poll\u2019s list', async () => { diff --git a/mobile/src/host-screen/use-host-worktree-actions.ts b/mobile/src/host-screen/use-host-worktree-actions.ts index 93347f9137b..07639fbd590 100644 --- a/mobile/src/host-screen/use-host-worktree-actions.ts +++ b/mobile/src/host-screen/use-host-worktree-actions.ts @@ -20,7 +20,6 @@ import { removeWorktreeRow } from '../worktree/worktree-host-row-identity' import { isWorktreePinned, type Worktree } from '../worktree/workspace-list-sections' -import { relayHostWorktreeSleep } from '../worktree/server-workspace-operations' import { worktreeActivate, worktreePinWrite, @@ -160,21 +159,13 @@ export function useHostWorktreeActions(args: { [client, clientForRow, worktrees, pinnedIds, updateLocalPins] ) - const sleepWorktree = useCallback( + const putWorktreeToSleep = useCallback( (item: Worktree) => { if (!client) { return } setSleptIds((prev) => new Set(prev).add(getWorktreeRowIdentity(item))) - // Why: the desktop sleeps a server's workspace in its own renderer, as it does its own. - const serverHost = item.hostId - const sleep = serverHost?.startsWith('runtime:') - ? relayHostWorktreeSleep.request(client, { - hostId: serverHost, - worktreeId: item.worktreeId - }) - : worktreeSleep.request(client, { worktree: `id:${item.worktreeId}` }) - void sleep.catch(() => null) + void worktreeSleep.request(client, { worktree: `id:${item.worktreeId}` }).catch(() => null) }, [client, setSleptIds] ) @@ -305,7 +296,7 @@ export function useHostWorktreeActions(args: { openNewWorktreeModal, openWorktreeSession, setShowNewWorktreeVisible, - sleepWorktree, + putWorktreeToSleep, togglePin } } diff --git a/mobile/src/host-screen/use-host-worktree-catalog.ts b/mobile/src/host-screen/use-host-worktree-catalog.ts index f03cfc8df56..2761d3ea493 100644 --- a/mobile/src/host-screen/use-host-worktree-catalog.ts +++ b/mobile/src/host-screen/use-host-worktree-catalog.ts @@ -16,17 +16,12 @@ import { savePinnedIds } from '../storage/preferences' import type { FetchHostRepoMetadata } from './use-host-repo-metadata' import type { HostScreenState } from './use-host-screen-state' import { relaysToServers } from '../transport/execution-host-scoped-rpc-client' -import type { ExecutionHostId } from '../../../src/shared/execution-host' import { applyServerWorkspaces, fetchServerWorkspaces, NO_SERVER_WORKSPACES } from '../worktree/server-workspaces' -function isServerHost(host: ExecutionHostId | undefined): boolean { - return host?.startsWith('runtime:') === true -} - export function useHostWorktreeCatalog(args: { client: RpcClient | null connState: ConnectionState @@ -82,16 +77,6 @@ export function useHostWorktreeCatalog(args: { const fetched = await fetchServerWorkspaces(requestClient) if (fetched && clientRef.current === requestClient) { setServerWorkspaces((previous) => applyServerWorkspaces(previous, fetched)) - const read = new Set( - fetched.hosts.filter((_host, index) => fetched.rows[index]).map((host) => host.hostId) - ) - setSleptIds((prev) => - retainLiveSleptWorktreeIdentities( - prev, - fetched.rows.flatMap((rows) => rows ?? []), - (host) => host !== undefined && read.has(host) - ) - ) } } catch { // Keeps the rows shown; the next poll retries. @@ -101,7 +86,7 @@ export function useHostWorktreeCatalog(args: { } } }, - [hostCapabilities, setSleptIds] + [hostCapabilities] ) const fetchWorktrees = useCallback( @@ -164,9 +149,7 @@ export function useHostWorktreeCatalog(args: { ) // Clear optimistic sleep overrides once the server confirms inactive (liveTerminalCount === 0). - setSleptIds((prev) => - retainLiveSleptWorktreeIdentities(prev, confirmed, (host) => !isServerHost(host)) - ) + setSleptIds((prev) => retainLiveSleptWorktreeIdentities(prev, confirmed)) // Sync pin state from server so desktop-initiated pins reflect without relying on stale AsyncStorage. const serverPinned = new Set(confirmed.filter((w) => w.isPinned).map((w) => w.worktreeId)) diff --git a/mobile/src/session/use-mobile-session-foundation.ts b/mobile/src/session/use-mobile-session-foundation.ts index de9d7bcf765..a19ef002bb0 100644 --- a/mobile/src/session/use-mobile-session-foundation.ts +++ b/mobile/src/session/use-mobile-session-foundation.ts @@ -5,7 +5,6 @@ import { useRouteHandoff } from '../navigation/route-handoff' import { HOST_DOCK_MIN_WIDTH } from '../storage/preferences' import { useForceReconnect } from '../transport/client-context' import { useWorkspaceClient } from '../transport/use-workspace-client' -import { useWorkspaceExecutionHost } from '../navigation/workspace-execution-host' import { useLastConnectedAt, useReconnectAttempt @@ -39,8 +38,7 @@ export function useMobileSessionFoundation() { const router = useRouteHandoff() const insets = useSafeAreaInsets() // Why: shared client per host owned by RpcClientProvider (docs/mobile-shared-client-per-host.md). - const { client, clientId, state: connState, desktopClient } = useWorkspaceClient(hostId) - const executionHost = useWorkspaceExecutionHost() + const { client, clientId, state: connState } = useWorkspaceClient(hostId) const { hostCapabilities } = useHostProtocolGates() const reconnectAttempts = useReconnectAttempt(hostId) const lastConnectedAt = useLastConnectedAt(hostId) @@ -104,8 +102,6 @@ export function useMobileSessionFoundation() { client, clientId, connState, - desktopClient, - executionHost, hostCapabilities, reconnectAttempts, lastConnectedAt, diff --git a/mobile/src/session/use-mobile-session-startup.test.tsx b/mobile/src/session/use-mobile-session-startup.test.tsx deleted file mode 100644 index 0d0b5744e51..00000000000 --- a/mobile/src/session/use-mobile-session-startup.test.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { createElement } from 'react' -import { act, create } from 'react-test-renderer' -import { describe, expect, it, vi } from 'vitest' -import type { RpcClient } from '../transport/rpc-client' -import { mountFixture } from '../test-support/rpc-recording/recorder-fixture-shape' -import { useMobileSessionStartup } from './use-mobile-session-startup' - -const WORKTREE_ID = 'server-repo::/srv/orca' - -function fakeClient(reply: (method: string) => unknown): RpcClient & { - sendRequest: ReturnType -} { - const sendRequest = vi.fn(async (method: string) => ({ - id: method, - ok: true, - result: reply(method) - })) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook only sends requests. - return { sendRequest } as unknown as RpcClient & { sendRequest: ReturnType } -} - -/** Opens a workspace's session route: on a server when `executionHost` is set. */ -async function openSession(options: { - client: RpcClient - desktopClient: RpcClient - executionHost?: 'runtime:env-1' -}) { - const showToast = vi.fn() - function Session() { - useMobileSessionStartup( - mountFixture[0]>({ - hostId: 'host-1', - worktreeId: WORKTREE_ID, - isFloatingWorkspaceRoute: false, - connState: 'connected', - client: options.client, - desktopClient: options.desktopClient, - executionHost: options.executionHost, - setTerminals: () => {}, - terminalsRef: { current: [] }, - setSessionTabs: () => {}, - appliedSnapshotMarkerRef: { current: { epoch: null, version: -1 } }, - closedTabTombstonesRef: { current: new Map() }, - setTerminalsLoaded: () => {}, - setActiveHandle: () => {}, - setActiveSessionTabId: () => {}, - setMarkdownDocs: () => {}, - setFileDocs: () => {}, - terminalGestureInputQueuesRef: { current: new Map() }, - terminalGestureInputInFlightRef: { current: new Set() }, - sessionTabActionSheetKeyboardHideSubRef: { current: null }, - sessionTabActionSheetRequestSeqRef: { current: 0 }, - initializedHandlesRef: { current: new Set() }, - terminalDiagnosticsRef: { current: { resetRoute: () => {} } }, - activeHandleRef: { current: null }, - activeSessionTabTypeRef: { current: null }, - pendingSelectionRef: { current: null }, - selectedSessionTabIdRef: { current: null }, - pendingBrowserFocusPageIdRef: { current: null }, - pendingTerminalActivationAttemptRef: { current: null }, - initialSessionAutoCreateRef: { current: null }, - bufferedTerminalDraftState: { resetDrafts: () => {}, clearPendingRestorations: () => {} }, - clearPendingLiveInputCommit: () => {}, - clearDelayedActionTimers: () => {}, - showToast, - clearTerminalCache: () => {}, - fetchTerminals: async () => true, - ensureSessionTabs: async () => {} - }) - ) - return null - } - let renderer: ReturnType | undefined - await act(async () => { - renderer = create(createElement(Session)) - }) - await act(async () => { - await Promise.resolve() - }) - act(() => renderer?.unmount()) - return { showToast } -} - -describe('opening a session wakes its slept agents where the desktop holds them', () => { - it("asks the desktop to wake a server workspace's agents, and says once when nothing could", async () => { - const server = fakeClient(() => ({ - activated: true, - sleepingAgentWake: 'unsupported-headless' - })) - const desktop = fakeClient(() => ({ sleepingAgentWake: 'unsupported-headless' })) - - const { showToast } = await openSession({ - client: server, - desktopClient: desktop, - executionHost: 'runtime:env-1' - }) - - expect(server.sendRequest.mock.calls.map(([method]) => method)).toEqual(['worktree.activate']) - expect(desktop.sendRequest.mock.calls.map(([method, params]) => [method, params])).toEqual([ - ['mobileRelay.hosts.wakeSleepingAgents', { hostId: 'runtime:env-1', worktreeId: WORKTREE_ID }] - ]) - expect(showToast.mock.calls).toEqual([['Open Orca on the host to wake sleeping agents.', 3000]]) - }) - - it("leaves the desktop's own workspace to its activation", async () => { - const desktop = fakeClient(() => ({ activated: true, sleepingAgentWake: 'requested' })) - - const { showToast } = await openSession({ client: desktop, desktopClient: desktop }) - - expect(desktop.sendRequest.mock.calls.map(([method]) => method)).toEqual(['worktree.activate']) - expect(showToast).not.toHaveBeenCalled() - }) -}) diff --git a/mobile/src/session/use-mobile-session-startup.ts b/mobile/src/session/use-mobile-session-startup.ts index 6d972f6dd8f..c9725e6ab10 100644 --- a/mobile/src/session/use-mobile-session-startup.ts +++ b/mobile/src/session/use-mobile-session-startup.ts @@ -1,7 +1,6 @@ import { useEffect } from 'react' import { worktreeActivate } from '../host-screen/host-screen-operations' import { headlessActivationNeedsHostRenderer } from '../worktree/worktree-activation-result' -import { relayHostSleepingAgentWake } from '../worktree/server-workspace-operations' import { createInitialSessionAutoCreateState } from './use-initial-session-terminal-autocreate' import type { MobileSessionKeyboardStateModel } from './use-mobile-session-keyboard-state' import type { RpcResponse } from '../transport/types' @@ -14,8 +13,6 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel) isFloatingWorkspaceRoute, connState, client, - desktopClient, - executionHost, setTerminals, terminalsRef, setSessionTabs, @@ -118,21 +115,17 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel) timers.push(setTimeout(fn, ms)) } void (async () => { - // Once per open: a server workspace hears from its server and from the desktop. - let headlessWakeShown = false - const reportHeadlessWake = (result: unknown): void => { - if (!disposed && !headlessWakeShown && headlessActivationNeedsHostRenderer(result)) { - headlessWakeShown = true - showToast('Open Orca on the host to wake sleeping agents.', 3000) - } - } // Why the reply rather than a verdict: both activations report through here, and only one of // them can fail to get a reply at all. Interpreting inside keeps the absent case spelled // `null` instead of a hand-built refusal that has to stay in step with the operation. const reportActivationOutcome = (response: RpcResponse | null): void => { const activation = response === null ? null : worktreeActivate.interpret(response) - if (activation?.accepted === true) { - reportHeadlessWake(activation.value) + if ( + !disposed && + activation?.accepted === true && + headlessActivationNeedsHostRenderer(activation.value) + ) { + showToast('Open Orca on the host to wake sleeping agents.', 3000) } } if (client && created !== '1' && !isFloatingWorkspaceRoute) { @@ -145,18 +138,6 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel) }) .then(reportActivationOutcome) .catch(() => null) - // Why: the desktop's renderer holds a server workspace's slept agents, as it does its own. - if (executionHost && desktopClient) { - void relayHostSleepingAgentWake - .request(desktopClient, { hostId: executionHost, worktreeId }) - .then((response) => { - const wake = relayHostSleepingAgentWake.interpret(response) - if (wake.accepted) { - reportHeadlessWake(wake.value) - } - }) - .catch(() => null) - } } if (disposed) { return @@ -204,8 +185,6 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel) client, connState, created, - desktopClient, - executionHost, fetchTerminals, ensureSessionTabs, isFloatingWorkspaceRoute, diff --git a/mobile/src/transport/use-workspace-client.ts b/mobile/src/transport/use-workspace-client.ts index 289bf088cba..61aa443a2ea 100644 --- a/mobile/src/transport/use-workspace-client.ts +++ b/mobile/src/transport/use-workspace-client.ts @@ -5,18 +5,9 @@ import type { RpcClient } from './rpc-client' // Provided by `WorkspaceRoute` for a server's workspace (null until reachable); absent otherwise. export const WorkspaceServerClientContext = createContext(undefined) -/** - * `useHostClient` for a workspace screen: its calls run where the workspace does. `desktopClient` - * is the paired desktop's own, for what it alone holds about the workspace. - */ -export function useWorkspaceClient( - hostId: string | undefined -): ReturnType & { desktopClient: RpcClient | null } { +/** `useHostClient` for a workspace screen: its calls run where the workspace does. */ +export function useWorkspaceClient(hostId: string | undefined): ReturnType { const host = useHostClient(hostId) const serverClient = useContext(WorkspaceServerClientContext) - return { - ...host, - client: serverClient === undefined ? host.client : serverClient, - desktopClient: host.client - } + return serverClient === undefined ? host : { ...host, client: serverClient } } diff --git a/mobile/src/worktree/server-workspace-operations.ts b/mobile/src/worktree/server-workspace-operations.ts index a02932c8557..71f76fa84c6 100644 --- a/mobile/src/worktree/server-workspace-operations.ts +++ b/mobile/src/worktree/server-workspace-operations.ts @@ -2,8 +2,6 @@ import { z } from 'zod' import type { ExecutionHostHealth } from '../../../src/shared/execution-host-health' import { parseExecutionHostId } from '../../../src/shared/execution-host' import { - MOBILE_RELAY_HOST_SLEEP_WORKTREE_METHOD, - MOBILE_RELAY_HOST_WAKE_SLEEPING_AGENTS_METHOD, MOBILE_RELAY_HOST_WORKTREES_METHOD, MOBILE_RELAY_HOSTS_LIST_METHOD, type MobileRelayHost, @@ -73,28 +71,3 @@ export const relayHostWorktreesRead = bindDeferredRpcOperation( ) }) ) - -/** Sleeping a server's workspace on the desktop, whose renderer runs the sleep. Fire and forget. */ -export const relayHostWorktreeSleep = bindDeferredRpcOperation( - defineRpcOperation({ - name: 'mobileRelay.host-sleep-worktree-or-skip', - method: MOBILE_RELAY_HOST_SLEEP_WORKTREE_METHOD, - acceptance: 'success-result-or-skip', - barrier: 'after-caller-barrier', - read: rpcResultVariant('relay-host-worktree-slept', z.unknown()) - }) -) - -/** - * Waking a server workspace's slept agents on the desktop that holds them. Read like - * `worktree.activate`'s reply: only a headless verdict is looked at, by a total guard. - */ -export const relayHostSleepingAgentWake = bindDeferredRpcOperation( - defineRpcOperation({ - name: 'mobileRelay.host-wake-sleeping-agents-or-skip', - method: MOBILE_RELAY_HOST_WAKE_SLEEPING_AGENTS_METHOD, - acceptance: 'success-result-or-skip', - barrier: 'after-caller-barrier', - read: rpcResultVariant('relay-host-sleeping-agents-woken', z.unknown()) - }) -) diff --git a/mobile/src/worktree/worktree-host-row-identity.ts b/mobile/src/worktree/worktree-host-row-identity.ts index 1b7defcfede..8e37ac0f74d 100644 --- a/mobile/src/worktree/worktree-host-row-identity.ts +++ b/mobile/src/worktree/worktree-host-row-identity.ts @@ -1,8 +1,4 @@ -import type { ExecutionHostId } from '../../../src/shared/execution-host' -import { - composeWorktreeHostIdentity, - getExecutionHostIdFromWorktreeHostIdentity -} from '../../../src/shared/worktree/host-qualified-identity' +import { composeWorktreeHostIdentity } from '../../../src/shared/worktree/host-qualified-identity' import type { Worktree } from './workspace-list-types' type WorktreeRowIdentitySource = Pick @@ -35,14 +31,9 @@ export function clearConfirmedActiveWorktreeIdentity( : pending } -/** - * Keeps an optimistic sleep until a list that speaks for the row's host shows it inactive. A list - * judges only its own hosts' rows: the desktop's list never carries a server's, and vice versa. - */ export function retainLiveSleptWorktreeIdentities( previous: Set, - confirmed: readonly Worktree[], - speaksFor: (host: ExecutionHostId | undefined) => boolean = () => true + confirmed: readonly Worktree[] ): Set { if (previous.size === 0) { return previous @@ -58,10 +49,7 @@ export function retainLiveSleptWorktreeIdentities( const still = new Set() for (const id of previous) { const wt = confirmedByIdentity.get(id) - if ( - !speaksFor(getExecutionHostIdFromWorktreeHostIdentity(id)) || - (wt && wt.liveTerminalCount > 0) - ) { + if (wt && wt.liveTerminalCount > 0) { still.add(id) } } diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index d1f3699edc5..77a949d7344 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -104,35 +104,33 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM await this.refreshMobileSessionPtyRecords() this.notifyMobileSessionTabsChanged(worktree.id) // Why: a phone open must also wake the worktree's slept agents (experimental - // agent sleep) — mobile-scoped so web/desktop are unaffected. + // agent sleep). Only the host renderer holds the sleeping records + wake + // authority, so fire-and-forget ask it — mobile-scoped so web/desktop are + // unaffected. Headless serve has no renderer to wake anything, so report + // that explicitly instead of letting mobile assume the agents resumed. if (opts.clientKind === 'mobile') { - sleepingAgentWake = this.requestSleepingAgentWake(worktree.id, getRepoExecutionHostId(repo)) + if (this.getAvailableAuthoritativeWindow()) { + this.notifier?.resumeSleepingAgents?.(worktree.id) + sleepingAgentWake = 'requested' + } else if ( + // Why: sleeping records are partitioned by execution host; reading + // only the local partition would miss slept agents on SSH-host + // worktrees and skip the headless warning for them. + Object.values( + this.store?.getWorkspaceSession?.(getRepoExecutionHostId(repo)) + .sleepingAgentSessionsByPaneKey ?? {} + ).some((record) => record.worktreeId === worktree.id) + ) { + // Why: headless is only degraded when this worktree actually has a + // persisted resume record. Ordinary mobile activation must not show + // an unsupported warning merely because no desktop window is open. + sleepingAgentWake = 'unsupported-headless' + } } } return { repoId: repo.id, worktreeId: worktree.id, activated: true, sleepingAgentWake } } - /** - * Wakes a worktree's slept agents for a phone. Only the host renderer holds the sleeping records - * and wake authority, so this asks it; with no renderer (headless serve, or the window closed) - * a worktree that has a persisted record reports that nothing could wake it. - */ - requestSleepingAgentWake( - worktreeId: string, - executionHostId: string - ): 'requested' | 'unsupported-headless' | 'not-applicable' { - if (this.getAvailableAuthoritativeWindow()) { - this.notifier?.resumeSleepingAgents?.(worktreeId) - return 'requested' - } - // Why the host's partition: sleeping records are partitioned by execution host. - const records = - this.store?.getWorkspaceSession?.(executionHostId).sleepingAgentSessionsByPaneKey ?? {} - return Object.values(records).some((record) => record.worktreeId === worktreeId) - ? 'unsupported-headless' - : 'not-applicable' - } - protected async buildStartupForDraft( repo: Repo, draft: string, diff --git a/src/main/runtime/orca-runtime-list-managed-worktrees.ts b/src/main/runtime/orca-runtime-list-managed-worktrees.ts index a848127fd6e..7fe067e9276 100644 --- a/src/main/runtime/orca-runtime-list-managed-worktrees.ts +++ b/src/main/runtime/orca-runtime-list-managed-worktrees.ts @@ -176,12 +176,7 @@ export class OrcaRuntimeWithListManagedWorktrees extends OrcaRuntimeWithRestoreS // Why: sleep is renderer-initiated on desktop (it tears down tab state // before killing PTYs). The notifier tells the renderer to run its own // sleep flow so all cleanup happens in the correct order. - return this.requestWorktreeSleep(worktree.id) - } - - /** Asks the renderer to sleep a worktree it shows, from any host, through its own sleep flow. */ - requestWorktreeSleep(worktreeId: string): { worktreeId: string } { - this.notifier?.sleepWorktree(worktreeId) - return { worktreeId } + this.notifier?.sleepWorktree(worktree.id) + return { worktreeId: worktree.id } } } diff --git a/src/main/runtime/orca-runtime-tests/mobile-relay-host-sleep-wake.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-relay-host-sleep-wake.spec.ts deleted file mode 100644 index 83ca2b4b717..00000000000 --- a/src/main/runtime/orca-runtime-tests/mobile-relay-host-sleep-wake.spec.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - OrcaRuntimeService, - electronMocks, - getDefaultWorkspaceSession -} from '../orca-runtime-test-mocks.spec' -import { TEST_WINDOW_ID, store } from '../orca-runtime-test-fixtures.spec' -import { RpcDispatcher } from '../rpc/dispatcher' -import { MOBILE_RELAY_HOSTS_METHODS } from '../rpc/methods/mobile-relay-hosts' - -const SERVER = 'runtime:env-1' -const SERVER_WORKTREE = 'server-repo::/srv/orca' - -/** A desktop whose renderer may or may not be open, holding slept agents in `sleptOnHost`'s partition. */ -function desktop(options: { windowOpen: boolean; sleptOnHost?: string }) { - const sleepWorktree = vi.fn() - const resumeSleepingAgents = vi.fn() - const getWorkspaceSession = (hostId: string) => ({ - ...getDefaultWorkspaceSession(), - sleepingAgentSessionsByPaneKey: - hostId === options.sleptOnHost - ? { - 'tab-1:leaf-1': { - paneKey: 'tab-1:leaf-1', - tabId: 'tab-1', - worktreeId: SERVER_WORKTREE, - agent: 'codex', - providerSession: { key: 'session_id', id: 'session-1' }, - prompt: 'test', - state: 'done', - capturedAt: 1, - updatedAt: 1, - origin: 'worktree-sleep' - } - } - : {} - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture store with one partitioned session reader. - const runtime = new OrcaRuntimeService({ ...store, getWorkspaceSession } as never) - runtime.setNotifier({ - worktreesChanged: vi.fn(), - reposChanged: vi.fn(), - activateWorktree: vi.fn(), - createTerminal: vi.fn(), - revealTerminalSession: vi.fn(), - splitTerminal: vi.fn(), - renameTerminal: vi.fn(), - focusTerminal: vi.fn(), - closeTerminal: vi.fn(), - sleepWorktree, - resumeSleepingAgents, - terminalFitOverrideChanged: vi.fn(), - terminalDriverChanged: vi.fn() - }) - electronMocks.BrowserWindow.fromId.mockReturnValue( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime reads only isDestroyed. - (options.windowOpen ? { isDestroyed: () => false } : null) as never - ) - runtime.attachWindow(TEST_WINDOW_ID) - runtime.markGraphReady(TEST_WINDOW_ID) - const dispatcher = new RpcDispatcher({ runtime, methods: MOBILE_RELAY_HOSTS_METHODS }) - // The desktop's configured servers, as its settings list them. - const mobileRelayHosts = { - list: () => ({ - hosts: [{ hostId: SERVER, label: 'VM', health: 'available', relay: 'ready' } as const] - }), - worktrees: async () => ({ worktrees: null }) - } - // The phone's socket path, which is what hands the handlers the desktop's servers. - const call = async (method: string, params: unknown): Promise => { - const replies: string[] = [] - await dispatcher.dispatchStreaming( - { id: 'request-1', authToken: 'token', method, params }, - (reply) => replies.push(reply), - { mobileRelayHosts } - ) - return JSON.parse(replies[0] ?? 'null') - } - return { call, sleepWorktree, resumeSleepingAgents } -} - -describe('a phone sleeping and waking a server workspace through its desktop', () => { - it("runs the desktop renderer's sleep for the server's workspace", async () => { - const { call, sleepWorktree } = desktop({ windowOpen: true }) - - const response = await call('mobileRelay.hosts.sleepWorktree', { - hostId: SERVER, - worktreeId: SERVER_WORKTREE - }) - - expect(response).toMatchObject({ ok: true, result: { worktreeId: SERVER_WORKTREE } }) - expect(sleepWorktree).toHaveBeenCalledWith(SERVER_WORKTREE) - }) - - it('asks the desktop renderer, which holds the slept agents, to wake them', async () => { - const { call, resumeSleepingAgents } = desktop({ windowOpen: true, sleptOnHost: SERVER }) - - const response = await call('mobileRelay.hosts.wakeSleepingAgents', { - hostId: SERVER, - worktreeId: SERVER_WORKTREE - }) - - expect(response).toMatchObject({ ok: true, result: { sleepingAgentWake: 'requested' } }) - expect(resumeSleepingAgents).toHaveBeenCalledWith(SERVER_WORKTREE) - }) - - it("says nothing could wake them with the window closed, reading that server's records", async () => { - const onServer = desktop({ windowOpen: false, sleptOnHost: SERVER }) - const elsewhere = desktop({ windowOpen: false, sleptOnHost: 'local' }) - const params = { hostId: SERVER, worktreeId: SERVER_WORKTREE } - - expect(await onServer.call('mobileRelay.hosts.wakeSleepingAgents', params)).toMatchObject({ - result: { sleepingAgentWake: 'unsupported-headless' } - }) - expect(await elsewhere.call('mobileRelay.hosts.wakeSleepingAgents', params)).toMatchObject({ - result: { sleepingAgentWake: 'not-applicable' } - }) - expect(onServer.resumeSleepingAgents).not.toHaveBeenCalled() - }) - - it("refuses a target that is not one of the desktop's configured servers", async () => { - const { call, sleepWorktree, resumeSleepingAgents } = desktop({ windowOpen: true }) - - for (const hostId of ['local', 'runtime:removed']) { - const params = { hostId, worktreeId: SERVER_WORKTREE } - expect(await call('mobileRelay.hosts.sleepWorktree', params)).toMatchObject({ ok: false }) - expect(await call('mobileRelay.hosts.wakeSleepingAgents', params)).toMatchObject({ - ok: false - }) - } - expect(sleepWorktree).not.toHaveBeenCalled() - expect(resumeSleepingAgents).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index c15a6ac37ad..a6ddef6650d 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -123,4 +123,3 @@ await import('./orca-runtime-tests/worktree-removal-failed-retry.spec') await import('./orca-runtime-tests/worktree-removal-execution-host.spec') await import('./orca-runtime-tests/targeting-and-resilience.spec') await import('./orca-runtime-tests/worktree-scan-cache-ttl.spec') -await import('./orca-runtime-tests/mobile-relay-host-sleep-wake.spec') diff --git a/src/main/runtime/rpc/methods/mobile-relay-hosts.ts b/src/main/runtime/rpc/methods/mobile-relay-hosts.ts index f34944900fe..9e438d70c76 100644 --- a/src/main/runtime/rpc/methods/mobile-relay-hosts.ts +++ b/src/main/runtime/rpc/methods/mobile-relay-hosts.ts @@ -1,20 +1,10 @@ -import { defineMethod, type RpcContext } from '../core' +import { defineMethod } from '../core' import { - MOBILE_RELAY_HOST_SLEEP_WORKTREE_METHOD, - MOBILE_RELAY_HOST_WAKE_SLEEPING_AGENTS_METHOD, MOBILE_RELAY_HOST_WORKTREES_METHOD, MOBILE_RELAY_HOSTS_LIST_METHOD, - MobileRelayHostWorktreeParamsSchema, MobileRelayHostWorktreesParamsSchema } from '../../../../shared/mobile-relay-hosts-contract' -/** Refuses a server the desktop has not configured; read from its settings, not a fetched list. */ -function assertConfiguredServer(hosts: RpcContext['mobileRelayHosts'], hostId: string): void { - if (!hosts?.list().hosts.some((host) => host.hostId === hostId)) { - throw new Error('selector_not_found') - } -} - export const MOBILE_RELAY_HOSTS_METHODS = [ defineMethod({ name: MOBILE_RELAY_HOSTS_LIST_METHOD, @@ -27,23 +17,5 @@ export const MOBILE_RELAY_HOSTS_METHODS = [ params: MobileRelayHostWorktreesParamsSchema, handler: async (params, ctx) => (await ctx.mobileRelayHosts?.worktrees(params.hostId)) ?? { worktrees: null } - }), - defineMethod({ - name: MOBILE_RELAY_HOST_SLEEP_WORKTREE_METHOD, - params: MobileRelayHostWorktreeParamsSchema, - handler: (params, { runtime, mobileRelayHosts }) => { - assertConfiguredServer(mobileRelayHosts, params.hostId) - return runtime.requestWorktreeSleep(params.worktreeId) - } - }), - defineMethod({ - name: MOBILE_RELAY_HOST_WAKE_SLEEPING_AGENTS_METHOD, - params: MobileRelayHostWorktreeParamsSchema, - handler: (params, { runtime, mobileRelayHosts }) => { - assertConfiguredServer(mobileRelayHosts, params.hostId) - return { - sleepingAgentWake: runtime.requestSleepingAgentWake(params.worktreeId, params.hostId) - } - } }) ] diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index e0ec3f2faf6..a5b552cbdbf 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -179,8 +179,6 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'markdown.readTab', 'markdown.saveTab', 'mobileRelay.hosts.list', - 'mobileRelay.hosts.sleepWorktree', - 'mobileRelay.hosts.wakeSleepingAgents', 'mobileRelay.hosts.worktrees', 'mobileWeb.bundle.chunk', 'mobileWeb.bundle.manifest', diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts index 4e88287d06a..82097fa21b4 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts @@ -12,11 +12,8 @@ export const PAIRED_DESKTOP_METHODS = [ 'notifications.unsubscribe', 'pairing.getEndpoints', 'pairing.provisionRelay', - // The configured servers the desktop shows, their workspace lists as it last fetched them, and - // the sleep and slept-agent wake its renderer owns for them. + // The configured servers the desktop shows, and their workspace lists as it last fetched them. 'mobileRelay.hosts.list', - 'mobileRelay.hosts.sleepWorktree', - 'mobileRelay.hosts.wakeSleepingAgents', 'mobileRelay.hosts.worktrees', 'speech.dictation.cancel', 'speech.dictation.chunk', diff --git a/src/shared/mobile-relay-hosts-contract.ts b/src/shared/mobile-relay-hosts-contract.ts index 1daf9895d15..11defa4482a 100644 --- a/src/shared/mobile-relay-hosts-contract.ts +++ b/src/shared/mobile-relay-hosts-contract.ts @@ -4,9 +4,6 @@ import type { ExecutionHostHealth } from './execution-host-health' // Why: both are answered by the paired desktop itself; the phone lists servers the desktop shows. export const MOBILE_RELAY_HOSTS_LIST_METHOD = 'mobileRelay.hosts.list' export const MOBILE_RELAY_HOST_WORKTREES_METHOD = 'mobileRelay.hosts.worktrees' -// Why the desktop's: its renderer alone sleeps a server's workspace and holds its slept agents. -export const MOBILE_RELAY_HOST_SLEEP_WORKTREE_METHOD = 'mobileRelay.hosts.sleepWorktree' -export const MOBILE_RELAY_HOST_WAKE_SLEEPING_AGENTS_METHOD = 'mobileRelay.hosts.wakeSleepingAgents' /** Whether the desktop can relay the phone to this server now; `update-needed` is the server's build. */ export type MobileRelayHostRelay = 'ready' | 'update-needed' | 'unavailable' @@ -24,14 +21,6 @@ export const MobileRelayHostWorktreesParamsSchema = z .object({ hostId: z.string().min(1).max(1024) }) .strict() -/** A server's workspace, by the server's host id and its own worktree id. */ -export const MobileRelayHostWorktreeParamsSchema = z - .object({ - hostId: z.string().startsWith('runtime:').max(1024), - worktreeId: z.string().min(1).max(4096) - }) - .strict() - /** One of a server's own `worktree.ps` rows (RuntimeWorktreePsSummary), carrying the server's host id. */ export type MobileRelayServerWorktreeRow = Record & { hostId: `runtime:${string}` } diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 814cb4f4b22..0f9e207e010 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -23,10 +23,7 @@ import { PairingGetEndpointsParamsSchema, PairingProvisionRelayParamsSchema } from '../mobile-relay-credential-contract' -import { - MobileRelayHostWorktreeParamsSchema, - MobileRelayHostWorktreesParamsSchema -} from '../mobile-relay-hosts-contract' +import { MobileRelayHostWorktreesParamsSchema } from '../mobile-relay-hosts-contract' import { MobileWebBundleChunkParamsSchema } from '../mobile-web-bundle/bundle-rpc-contract' import { OrcadMigrationSnapshotChunkRequestSchema } from '../orcad-migration-scrollback' import { OrcadTerminalCensusParamsSchema } from '../orcad-terminal-census' @@ -1035,8 +1032,6 @@ export const RPC_PARAMS_BY_METHOD = { 'markdown.readTab': ActivateTab, 'markdown.saveTab': SaveMarkdownTab, 'mobileRelay.hosts.list': null, - 'mobileRelay.hosts.sleepWorktree': MobileRelayHostWorktreeParamsSchema, - 'mobileRelay.hosts.wakeSleepingAgents': MobileRelayHostWorktreeParamsSchema, 'mobileRelay.hosts.worktrees': MobileRelayHostWorktreesParamsSchema, 'mobileWeb.bundle.chunk': MobileWebBundleChunkParamsSchema, 'mobileWeb.bundle.manifest': null, diff --git a/tests/e2e/paired-mobile-desktop-mirror.spec.ts b/tests/e2e/paired-mobile-desktop-mirror.spec.ts index fcdc392fb48..6dcb8f102da 100644 --- a/tests/e2e/paired-mobile-desktop-mirror.spec.ts +++ b/tests/e2e/paired-mobile-desktop-mirror.spec.ts @@ -24,9 +24,6 @@ const CreatedTabSchema = z.object({ tab: z.looseObject({ id: z.string(), termina const ReposSchema = z.object({ repos: z.array(z.looseObject({ id: z.string(), path: z.string() })) }) -const ServerTerminalsSchema = z.looseObject({ - terminals: z.array(z.looseObject({ handle: z.string() })) -}) const ServerTabsSchema = z.looseObject({ tabs: z.array(z.looseObject({ id: z.string() })) }) const SubscribedSchema = z.looseObject({ type: z.literal('subscribed'), streamId: z.number() }) const HostsSchema = z.object({ @@ -324,7 +321,7 @@ function desktopTerminalHandles(desktop: { page: Page }, worktreeId: string): Pr ) } -test('phone tab, workspace and sleep actions on a server show on the server and the desktop', async (// oxlint-disable-next-line no-empty-pattern -- this test owns its topology launch. +test('phone tab and workspace actions on a server show on the server and the desktop', async (// oxlint-disable-next-line no-empty-pattern -- this test owns its topology launch. {}, testInfo) => { test.setTimeout(180_000) const serverFolder = testInfo.outputPath('server-folder') @@ -387,10 +384,6 @@ test('phone tab, workspace and sleep actions on a server show on the server and const serverTabs = async () => ServerTabsSchema.parse((await host.client.call('session.tabs.list', { worktree })).result) .tabs - const serverTerminals = async () => - ServerTerminalsSchema.parse( - (await host.client.call('terminal.list', { worktree })).result - ).terminals.map((terminal) => terminal.handle) const create = (id: string) => call( socket, @@ -427,33 +420,6 @@ test('phone tab, workspace and sleep actions on a server show on the server and await expect .poll(() => desktopTerminalHandles(desktop, worktreeId), { timeout: 30_000 }) .toEqual([kept.terminal]) - - // Waking and sleeping are the desktop renderer's. The wake check proves delivery to it, not an - // agent resuming (none is slept here). - await desktop.page.evaluate(() => { - const woken: string[] = [] - Object.assign(window, { __wokenWorktrees: woken }) - window.api.ui.onResumeSleepingAgents(({ worktreeId }) => woken.push(worktreeId)) - }) - const target = { hostId: serverHostId, worktreeId } - const wake = await call( - socket, - { id: 'wake', method: 'mobileRelay.hosts.wakeSleepingAgents', params: target }, - z.looseObject({ sleepingAgentWake: z.string() }) - ) - expect(wake.sleepingAgentWake).toBe('requested') - await expect - .poll(() => desktop.page.evaluate(() => Reflect.get(window, '__wokenWorktrees'))) - .toEqual([worktreeId]) - expect(await serverTerminals()).toEqual([kept.terminal]) - await call( - socket, - { id: 'sleep', method: 'mobileRelay.hosts.sleepWorktree', params: target }, - z.unknown() - ) - // The desktop's sleep releases the workspace's processes on the server and keeps its tabs. - await expect.poll(serverTerminals, { timeout: 30_000 }).toEqual([]) - expect(await desktopTerminalHandles(desktop, worktreeId)).toEqual([kept.terminal]) } finally { await dispose() }