From d3461a541249387e0a34ae921e174b8ee804bf80 Mon Sep 17 00:00:00 2001 From: Wang Date: Sat, 29 Aug 2026 16:47:19 +0800 Subject: [PATCH 1/5] fix(desktop): stabilize local peer recovery Hide retired Mesh memberships, serialize interrupted managed-service recovery with Desktop reconnect, preserve actionable retirement facts, and tolerate the bounded service readiness gap during pairing. Avoid redundant Cargo stripping in development builds. Generated-by: OpenAI Codex --- .../runtime-host-desktop-manager.test.ts | 29 +++++++++++ .../runtime-host-local-remote-access.test.ts | 6 +++ .../src/main/runtime-host-desktop-manager.ts | 50 +++++++++++++++++-- .../main/runtime-host-local-remote-access.ts | 35 +++++++------ native/runtime-host-peer/Cargo.toml | 4 ++ .../src/__tests__/runtime-host-setup.test.ts | 27 ++++++---- .../cli/src/runtime-host-access-command.ts | 21 ++++++-- .../cli/src/runtime-host-setup-command.ts | 27 ++++++++-- .../src/__tests__/peer-mesh.test.ts | 2 +- packages/runtime-host/src/peer-mesh/node.ts | 5 +- 10 files changed, 164 insertions(+), 42 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts index d2b9accf06..df12fa116d 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts @@ -911,6 +911,35 @@ test('stops reconnecting when the replacement Host is incompatible', async () => await first.candidate.close(); const fatal = await fatalReported; assert.match(fatal.message, /older Runtime Host/); + await assert.rejects( + owner.retireOwnedLocalHost('interrupt_active_work'), + (error: unknown) => + error instanceof DesktopLocalHostRetirementError && + error.facts.pid === 42 && + error.cause === fatal, + ); + await owner.close(); +}); + +test('does not block quit after a supervised Local Host becomes permanently unavailable', async () => { + const first = candidateHarness({ ownership: 'supervised' }); + let reportFatal!: (error: Error) => void; + const fatalReported = new Promise((resolve) => { + reportFatal = resolve; + }); + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => + first.closeCalls === 0 + ? ready(first.candidate) + : incompatibleHost('wait_for_idle_exit'), + onFatalError: reportFatal, + }); + + await first.candidate.close(); + await fatalReported; + assert.deepEqual(await owner.retireOwnedLocalHost('interrupt_active_work'), { + kind: 'not_owned', + }); await owner.close(); }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts index 1fbc1844b5..7e6be16b68 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts @@ -217,6 +217,7 @@ test('an interrupted Local Host handoff converges to its exact managed service', })}\n`, ); let setupCalls = 0; + let setupQuiesced = false; const service = createDesktopLocalRuntimeHostRemoteAccess({ ipcMain: { handle() {}, removeHandler() {} }, clientDataRoot, @@ -229,6 +230,10 @@ test('an interrupted Local Host handoff converges to its exact managed service', assert.equal(mode, 'interrupt_active_work'); return { kind: 'not_owned' as const }; }, + async runManagedLocalHostChange(change: () => Promise) { + setupQuiesced = true; + return change(); + }, }) as unknown as RuntimeHostDesktopManager, resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), operator: { @@ -256,6 +261,7 @@ test('an interrupted Local Host handoff converges to its exact managed service', await service.recover(); assert.equal(setupCalls, 1); + assert.equal(setupQuiesced, true); assert.equal( JSON.parse(await readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8')) .state, diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index c03c6f6af9..d9e25ca053 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -41,6 +41,7 @@ import { type DesktopRuntimeHostCandidate, type DesktopRuntimeHostCandidateStartInput, type DesktopRuntimeHostCandidateStartResult, + type DesktopRuntimeHostOwnership, } from './runtime-host-desktop-candidate.js'; import { RuntimeHostReconnectingIpcMain } from './runtime-host-reconnecting-ipc-main.js'; import { RuntimeHostSessionObservationRegistry } from './runtime-host-session-observation-registry.js'; @@ -181,6 +182,12 @@ interface DesktopRuntimeHostTargetGeneration { hostId?: string; lifecycle?: RuntimeHostReconnectLifecycle; unsubscribeLifecycle?: () => void; + lastCandidate?: { + readonly hostId: string; + readonly hostEpoch: string; + readonly ownership: DesktopRuntimeHostOwnership; + readonly pid?: number; + }; valid: boolean; } @@ -576,10 +583,20 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { async #retireOwnedLocalHost( mode: RuntimeHostRetirementMode, ): Promise { - const lifecycle = this.#requireLifecycle( - this.#requireTarget(LOCAL_RUNTIME_HOST_PROFILE.id), - ); - const quiescence = await lifecycle.quiesce(); + const target = this.#requireTarget(LOCAL_RUNTIME_HOST_PROFILE.id); + const lifecycle = this.#requireLifecycle(target); + const unavailable = this.#unavailableLocalHostRetirement(target); + if (unavailable) return unavailable; + let quiescence: Awaited< + ReturnType['quiesce']> + >; + try { + quiescence = await lifecycle.quiesce(); + } catch (error) { + const terminal = this.#unavailableLocalHostRetirement(target, error); + if (terminal) return terminal; + throw error; + } let hostPid = quiescence.current.hostPid; let launchBarrierPaused = false; const resume = () => { @@ -626,6 +643,25 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { } } + #unavailableLocalHostRetirement( + target: DesktopRuntimeHostTargetGeneration, + cause: unknown = target.state.readiness === 'unavailable' ? target.state.error : undefined, + ): DesktopLocalHostRetirement | undefined { + if (target.state.readiness !== 'unavailable') return undefined; + const last = target.lastCandidate; + if (!last || last.ownership !== 'owned_ephemeral') return { kind: 'not_owned' }; + throw new DesktopLocalHostRetirementError( + { + hostId: last.hostId, + hostEpoch: last.hostEpoch, + lifecycleMode: 'ephemeral', + rootPath: this.#baseInput.rootPath, + ...(last.pid === undefined ? {} : { pid: last.pid }), + }, + { cause: cause instanceof Error ? cause : new Error(String(cause)) }, + ); + } + #completeLocalHostRetirement( resume: () => void, ): Extract { @@ -743,6 +779,12 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { ); if (result.kind === 'ready') { target.hostId = result.candidate.client.hostId; + target.lastCandidate = { + hostId: result.candidate.client.hostId, + hostEpoch: result.candidate.client.hostEpoch, + ownership: result.candidate.hostOwnership, + ...(result.candidate.hostPid === undefined ? {} : { pid: result.candidate.hostPid }), + }; return result.candidate; } if (result.kind === 'upgrade_required' && result.restartable) { diff --git a/apps/desktop/src/main/runtime-host-local-remote-access.ts b/apps/desktop/src/main/runtime-host-local-remote-access.ts index d19b3803bd..723bada649 100644 --- a/apps/desktop/src/main/runtime-host-local-remote-access.ts +++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts @@ -247,22 +247,29 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { } let target: LocalServiceTarget | undefined; try { - const complete = await input.operator.runSetup( - { - setupPackage, - clientDataRoot: input.clientDataRoot, - rootPath: handoff.rootPath, - principalId: LOCAL_REMOTE_ACCESS_PRINCIPAL_ID, - coordinationRelays: handoff.coordinationRelays, - expectedTarget: { - serviceId: handoff.rootId, + const runSetup = () => + input.operator.runSetup( + { + setupPackage, + clientDataRoot: input.clientDataRoot, rootPath: handoff.rootPath, - rootId: handoff.rootId, + principalId: LOCAL_REMOTE_ACCESS_PRINCIPAL_ID, + coordinationRelays: handoff.coordinationRelays, + expectedTarget: { + serviceId: handoff.rootId, + rootPath: handoff.rootPath, + rootId: handoff.rootId, + }, + signal: closing.signal, }, - signal: closing.signal, - }, - () => undefined, - ); + () => undefined, + ); + // Recovery may find that the operator already owns the root. Its setup + // can still restart that Host, so keep Desktop reconnect quiesced across + // the entire reconciliation just like every other managed-service change. + const complete = retirement.kind === 'not_owned' + ? await manager.runManagedLocalHostChange(runSetup) + : await runSetup(); if ( complete.serviceId !== handoff.rootId || complete.rootPath !== handoff.rootPath || diff --git a/native/runtime-host-peer/Cargo.toml b/native/runtime-host-peer/Cargo.toml index 01e367934e..35c4f35a36 100644 --- a/native/runtime-host-peer/Cargo.toml +++ b/native/runtime-host-peer/Cargo.toml @@ -49,3 +49,7 @@ tokio = { version = "1.53", features = ["fs", "io-util", "rt-multi-thread", "syn [build-dependencies] napi-build = "2.4" + +[profile.release] +# build.mjs strips the copied addon once; Cargo stripping every intermediate artifact is redundant. +strip = "none" diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts index 6c6f1f4e82..0854089df8 100644 --- a/packages/cli/src/__tests__/runtime-host-setup.test.ts +++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts @@ -59,6 +59,7 @@ import { resolveRuntimeHostManagedDeploymentRoot, } from '../runtime-host-managed-deployment.js'; import { runRuntimeHostSetupCli } from '../runtime-host-setup-command.js'; +import { RuntimeHostAccessUnavailableError } from '../runtime-host-access-command.js'; import { manageRuntimeHostManagedLifecycle } from '../runtime-host-managed-lifecycle-manager.js'; import { resolveRuntimeHostLifecycleProvider, @@ -86,6 +87,7 @@ test('on-demand setup installs one exact deployment without a service backend', const outputs: string[] = []; let rootId = ''; let projectedOperatorDeploymentRoot = ''; + let pairingAttempts = 0; t.after(async () => { if (previousDataHome === undefined) delete process.env.XDG_DATA_HOME; else process.env.XDG_DATA_HOME = previousDataHome; @@ -166,16 +168,20 @@ test('on-demand setup installs one exact deployment without a service backend', projectedOperatorDeploymentRoot = desired?.deploymentRoot ?? ''; }, verifyOperator: async () => undefined, - replaceCredential: async () => ({ - rootId, - credential: 'secret-token', - credentialId: 'credential-1', - principalKind: 'remote_owner' as const, - principalId: 'desktop:client-1', - operationGrants: [] as const, - canPublishClientCapabilities: false, - canUseHostPaths: false, - }), + replaceCredential: async () => { + pairingAttempts += 1; + if (pairingAttempts === 1) throw new RuntimeHostAccessUnavailableError('unavailable'); + return { + rootId, + credential: 'secret-token', + credentialId: 'credential-1', + principalKind: 'remote_owner' as const, + principalId: 'desktop:client-1', + operationGrants: [] as const, + canPublishClientCapabilities: false, + canUseHostPaths: false, + }; + }, verifyCredential: async ({ endpoint, rootId: expectedRootId }) => { assert.equal(endpoint, 'ws://127.0.0.1:43210/runtime-host'); assert.equal(expectedRootId, rootId); @@ -183,6 +189,7 @@ test('on-demand setup installs one exact deployment without a service backend', writeOutput: (value) => outputs.push(value), } satisfies NonNullable[1]>; assert.equal(await runRuntimeHostSetupCli(options, overrides), 0); + assert.equal(pairingAttempts, 2); const complete = outputs .map(decodeRuntimeHostSetupFrame) .find((frame) => frame?.kind === 'complete'); diff --git a/packages/cli/src/runtime-host-access-command.ts b/packages/cli/src/runtime-host-access-command.ts index 40f4bd2079..47311f16af 100644 --- a/packages/cli/src/runtime-host-access-command.ts +++ b/packages/cli/src/runtime-host-access-command.ts @@ -45,6 +45,16 @@ const PROTOCOL = { max: RUNTIME_HOST_PROTOCOL_VERSION, } as const; +export class RuntimeHostAccessUnavailableError extends Error { + constructor( + readonly reason: string, + options?: ErrorOptions, + ) { + super(`Runtime Host service is not available (${reason})`, options); + this.name = 'RuntimeHostAccessUnavailableError'; + } +} + export interface RuntimeHostAccessIssueOptions { readonly rootPath: string; readonly expectedRootId?: string; @@ -350,12 +360,13 @@ export async function revokeRuntimeHostAccessCredential( } async function connectLocalOwner(rootPath: string, expectedRootId?: string) { - const result = await connectExistingRuntimeHost({ - rootPath, - protocol: PROTOCOL, - }); + const result = await connectExistingRuntimeHost({ rootPath, protocol: PROTOCOL }).catch( + (error: unknown) => { + throw new RuntimeHostAccessUnavailableError('connection_failed', { cause: error }); + }, + ); if (result.kind !== 'connected') { - throw new Error(`Runtime Host service is not available (${result.kind})`); + throw new RuntimeHostAccessUnavailableError(result.kind); } if (expectedRootId && result.connection.rootId !== expectedRootId) { await result.connection.close(); diff --git a/packages/cli/src/runtime-host-setup-command.ts b/packages/cli/src/runtime-host-setup-command.ts index 275213356a..5a933d9383 100644 --- a/packages/cli/src/runtime-host-setup-command.ts +++ b/packages/cli/src/runtime-host-setup-command.ts @@ -22,6 +22,7 @@ import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; import { isDeepStrictEqual } from 'node:util'; import { truncateUtf8 } from '@maka/core/diagnostic-log'; +import { generalizedErrorMessage } from '@maka/core/redaction'; import { activateRuntimeHostManagedDeployment, connectRemoteRuntimeHost, @@ -47,6 +48,7 @@ import { prepareRuntimeHostAccessCredential, replaceRuntimeHostAccessCredential, revokeRuntimeHostAccessCredential, + RuntimeHostAccessUnavailableError, type RuntimeHostAccessPreset, } from './runtime-host-access-command.js'; import { @@ -118,6 +120,8 @@ import { import { activateRuntimeHostManagedDeploymentWithReconciliation } from './runtime-host-activation-command.js'; const SETUP_LOCK_TIMEOUT_MS = 5 * 60_000; +const PAIRING_AVAILABILITY_TIMEOUT_MS = 10_000; +const PAIRING_AVAILABILITY_POLL_MS = 100; export interface RuntimeHostSetupCliOptions { readonly json: boolean; @@ -1075,20 +1079,35 @@ async function pairAndVerifyRuntimeHostSetup( const pairCredential = options.deferPairingCommit ? deps.prepareCredential : deps.replaceCredential; - paired = await pairCredential({ + const credentialInput = { rootPath: target.rootPath, - principalKind: 'remote_owner', + principalKind: 'remote_owner' as const, principalId: options.principalId, operationGrants: [], canPublishClientCapabilities: false, canUseHostPaths: false, preset: options.preset, ...(options.bindPairingToClient ? { bindClientInstance: true } : {}), - }); + }; + const deadline = Date.now() + PAIRING_AVAILABILITY_TIMEOUT_MS; + while (true) { + try { + paired = await pairCredential(credentialInput); + break; + } catch (error) { + if (!(error instanceof RuntimeHostAccessUnavailableError) || Date.now() >= deadline) { + throw error; + } + await new Promise((resolveWait) => + setTimeout(resolveWait, Math.min(PAIRING_AVAILABILITY_POLL_MS, deadline - Date.now())), + ); + } + } } catch (error) { + const reason = generalizedErrorMessage(error, 'Runtime Host access service is unavailable'); throw new RuntimeHostSetupError( 'pairing_failed', - 'Runtime Host could not pair the requested Client identity', + `Runtime Host could not pair the requested Client identity: ${reason}`, { cause: error }, ); } diff --git a/packages/runtime-host/src/__tests__/peer-mesh.test.ts b/packages/runtime-host/src/__tests__/peer-mesh.test.ts index 78c598fe6c..8ee98365f2 100644 --- a/packages/runtime-host/src/__tests__/peer-mesh.test.ts +++ b/packages/runtime-host/src/__tests__/peer-mesh.test.ts @@ -233,7 +233,7 @@ test('closed Mesh records do not permanently consume membership capacity', async if (index === 0) assert.equal(await hasActivePeerMeshMembership(root, 'peer-a'), false); } assert.equal((await node.create()).roster.roster.closed, false); - assert.equal(node.status().length, 16); + assert.equal(node.status().length, 1); } finally { await node.close(); await peer.close(); diff --git a/packages/runtime-host/src/peer-mesh/node.ts b/packages/runtime-host/src/peer-mesh/node.ts index edbbdcbe53..4aa43b614a 100644 --- a/packages/runtime-host/src/peer-mesh/node.ts +++ b/packages/runtime-host/src/peer-mesh/node.ts @@ -233,10 +233,7 @@ class PeerMeshNodeImpl implements PeerMeshNode { return Object.freeze( this.#store .read() - .meshes.filter( - (state) => - state.role === 'authority' || state.roster.roster.members.includes(identity.peerId), - ) + .meshes.filter((state) => isActiveMembership(state, identity.peerId)) .map((state) => peerMeshStatus(state, identity, this.#store.read().routes, this.#now())), ); } From afb8a5790f4d7d28eee4dde995b5b433a3360d0d Mon Sep 17 00:00:00 2001 From: Wang Date: Sat, 29 Aug 2026 17:16:54 +0800 Subject: [PATCH 2/5] fix(desktop): recover managed handoff ownership Generated-by: OpenAI Codex --- .../runtime-host-desktop-manager.test.ts | 57 +++++++++++- .../runtime-host-local-remote-access.test.ts | 63 +++++++++++++ apps/desktop/src/main/runtime-host-boot.ts | 2 + .../main/runtime-host-desktop-candidate.ts | 7 ++ .../src/main/runtime-host-desktop-manager.ts | 57 +++++++++++- .../main/runtime-host-local-remote-access.ts | 90 +++++++++++++------ 6 files changed, 244 insertions(+), 32 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts index df12fa116d..69aba09a33 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts @@ -23,6 +23,7 @@ import type { BotIncomingMessage } from '@maka/runtime/bots'; import { RuntimeHostOperationError, RuntimeHostRequestInterruptedError, + type RuntimeHostSpawnedProcess, } from '@maka/runtime-host/client'; import { INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, @@ -894,8 +895,33 @@ test('keeps reconnecting through transient startup failures until the Desktop ad await owner.close(); }); +test('reconciles an interrupted managed handoff before retrying Local discovery', async () => { + const managed = candidateHarness({ ownership: 'supervised' }); + const events: string[] = []; + let starts = 0; + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => { + starts += 1; + events.push(`discover:${starts}`); + return starts === 1 + ? { kind: 'failed', reason: 'managed_root_requires_operator' } + : ready(managed.candidate); + }, + recoverManagedLocalHost: async () => { + events.push('reconcile'); + return true; + }, + }); + + assert.deepEqual(events, ['discover:1', 'reconcile', 'discover:2']); + assert.equal(owner.current('local')?.candidate?.hostOwnership, 'supervised'); + await owner.close(); +}); + test('stops reconnecting when the replacement Host is incompatible', async () => { - const first = candidateHarness(); + const first = candidateHarness({ + ownedProcess: { pid: 42, exited: new Promise(() => undefined) }, + }); let reportFatal!: (error: Error) => void; const fatalReported = new Promise((resolve) => { reportFatal = resolve; @@ -921,6 +947,33 @@ test('stops reconnecting when the replacement Host is incompatible', async () => await owner.close(); }); +test('does not retain manual-stop authority after the owned Host process exits', async () => { + const first = candidateHarness({ + ownedProcess: { + pid: 42, + exited: Promise.resolve({ code: 0, signal: null, stderr: '', stderrTruncated: false }), + }, + }); + let reportFatal!: (error: Error) => void; + const fatalReported = new Promise((resolve) => { + reportFatal = resolve; + }); + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => + first.closeCalls === 0 + ? ready(first.candidate) + : incompatibleHost('wait_for_idle_exit'), + onFatalError: reportFatal, + }); + + await first.candidate.close(); + await fatalReported; + assert.deepEqual(await owner.retireOwnedLocalHost('interrupt_active_work'), { + kind: 'not_owned', + }); + await owner.close(); +}); + test('does not block quit after a supervised Local Host becomes permanently unavailable', async () => { const first = candidateHarness({ ownership: 'supervised' }); let reportFatal!: (error: Error) => void; @@ -1189,6 +1242,7 @@ function candidateHarness( disconnectOnPrepare?: boolean; activeTasks?: boolean | 'always'; ownership?: 'owned_ephemeral' | 'supervised' | 'external'; + ownedProcess?: RuntimeHostSpawnedProcess; hostId?: string; hostEpoch?: string; finalizeFailures?: Error[]; @@ -1213,6 +1267,7 @@ function candidateHarness( closed, hostOwnership: options.ownership ?? 'owned_ephemeral', hostPid: 42, + ...(options.ownedProcess ? { ownedProcess: options.ownedProcess } : {}), client: { hostId: options.hostId ?? 'test-host', hostEpoch: options.hostEpoch ?? 'test-host-epoch', diff --git a/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts index 7e6be16b68..a40c2e6801 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts @@ -198,6 +198,66 @@ test('revokes the one Local sharing authority without changing peer connectivity ]); }); +test('reconciles a committed handoff when Local discovery finds a managed root gap', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-local-remote-access-prestart-')); + t.after(() => rm(base, { recursive: true, force: true })); + const clientDataRoot = join(base, 'client'); + const rootPath = join(clientDataRoot, 'workspaces', 'default'); + const rootId = 'a'.repeat(64); + await mkdir(rootPath, { recursive: true }); + await writeFile( + join(clientDataRoot, 'runtime-host-local-service.json'), + `${JSON.stringify({ + schemaVersion: 1, + state: 'handoff', + rootPath, + rootId, + coordinationRelays: [], + allowInterruptActiveTasks: true, + })}\n`, + ); + let setupCalls = 0; + const service = createDesktopLocalRuntimeHostRemoteAccess({ + ipcMain: { handle() {}, removeHandler() {} }, + clientDataRoot, + rootPath, + rootId, + directPeerAvailable: true, + manager: () => assert.fail('pre-start reconciliation must not require the Local manager'), + hasManagedDeploymentAuthority: async () => true, + resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), + operator: { + async runSetup() { + setupCalls += 1; + return { + serviceId: rootId, + operatorPath: join(base, 'operator'), + rootPath, + rootId, + deploymentId: '22222222-2222-4222-8222-222222222222', + credential: 'unused-pending-credential', + directPeer: { + peerId: '12D3KooWpeer', + routeHints: ['/ip4/192.0.2.1/udp/41000/quic-v1'], + coordinationRelays: [], + }, + }; + }, + async close() {}, + } as unknown as ReturnType, + }); + t.after(() => service.close()); + + assert.equal(await service.recoverManagedHostForConnect(), true); + + assert.equal(setupCalls, 1); + assert.equal( + JSON.parse(await readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8')) + .state, + 'managed', + ); +}); + test('an interrupted Local Host handoff converges to its exact managed service', async (t) => { const base = await mkdtemp(join(tmpdir(), 'maka-local-remote-access-recovery-')); t.after(() => rm(base, { recursive: true, force: true })); @@ -235,6 +295,7 @@ test('an interrupted Local Host handoff converges to its exact managed service', return change(); }, }) as unknown as RuntimeHostDesktopManager, + hasManagedDeploymentAuthority: async () => false, resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), operator: { async runSetup() { @@ -258,6 +319,8 @@ test('an interrupted Local Host handoff converges to its exact managed service', }); t.after(() => service.close()); + assert.equal(await service.recoverManagedHostForConnect(), false); + assert.equal(setupCalls, 0); await service.recover(); assert.equal(setupCalls, 1); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 0913bbee60..d9fa8a68d0 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -991,6 +991,8 @@ runtimeHostManager = await startRuntimeHostDesktopManager( isDefault: true, }); }, + recoverManagedLocalHost: (signal) => + localRuntimeHostRemoteAccess.recoverManagedHostForConnect(signal), onFatalError: (error, target) => { if (error instanceof RuntimeHostUpgradeCancelledError) { if (target.profile.kind === "local") app.quit(); diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 3ecce8214c..03ce20e79e 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -201,6 +201,7 @@ export interface DesktopRuntimeHostCandidate { readonly closed: Promise; readonly hostOwnership: DesktopRuntimeHostOwnership; readonly hostPid?: number; + readonly ownedProcess?: RuntimeHostSpawnedProcess; stopSession(sessionId: string): Promise; close(): Promise; } @@ -211,6 +212,7 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { readonly closed: Promise; readonly hostOwnership: DesktopRuntimeHostOwnership; readonly hostPid: number | undefined; + readonly ownedProcess: RuntimeHostSpawnedProcess | undefined; readonly #client: DesktopRuntimeHostClient; readonly #observer: RuntimeHostSessionObserver; readonly #ipc: ScopedIpcMain; @@ -237,6 +239,7 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { connectionClosed: Promise; hostOwnership: DesktopRuntimeHostOwnership; hostPid?: number; + ownedProcess?: RuntimeHostSpawnedProcess; hasRegisteredCapabilities: () => boolean; stopSession: (sessionId: string) => Promise; }) { @@ -255,6 +258,7 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { this.botIncoming = input.botIncoming; this.hostOwnership = input.hostOwnership; this.hostPid = input.hostPid; + this.ownedProcess = input.ownedProcess; this.closed = input.connectionClosed.then(() => this.close()); } @@ -323,6 +327,7 @@ export async function startDesktopRuntimeHostCandidate( : 'supervised', "local", connection.registration.pid, + connection.spawnedProcess, ), }; } catch (error) { @@ -432,6 +437,7 @@ export async function createDesktopRuntimeHostCandidate( hostOwnership: DesktopRuntimeHostOwnership, targetKind: DesktopRuntimeHostTargetPolicy["kind"], hostPid?: number, + ownedProcess?: RuntimeHostSpawnedProcess, ): Promise { const target: DesktopRuntimeHostTargetPolicy = { kind: targetKind, @@ -760,6 +766,7 @@ export async function createDesktopRuntimeHostCandidate( connectionClosed: connection.closed, hostOwnership, ...(hostPid === undefined ? {} : { hostPid }), + ...(ownedProcess === undefined ? {} : { ownedProcess }), hasRegisteredCapabilities: () => capabilitiesRegistered, stopSession, }); diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index d9e25ca053..380451aac9 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -186,11 +186,16 @@ interface DesktopRuntimeHostTargetGeneration { readonly hostId: string; readonly hostEpoch: string; readonly ownership: DesktopRuntimeHostOwnership; - readonly pid?: number; + readonly ownedProcess?: DesktopOwnedProcessEvidence; }; valid: boolean; } +interface DesktopOwnedProcessEvidence { + readonly pid: number; + state: 'running' | 'exited' | 'unknown'; +} + export async function startRuntimeHostDesktopManager( input: DesktopRuntimeHostCandidateStartInput, options: { @@ -205,6 +210,7 @@ export async function startRuntimeHostDesktopManager( registration: HostRegistration, signal: AbortSignal, ) => Promise; + recoverManagedLocalHost?: (signal: AbortSignal) => Promise; reconnectBackoff?: RuntimeHostReconnectBackoff; pairingFinalizationTimeoutMs?: number; onTargetStateChanged?: (state: RuntimeHostDesktopTargetState) => void; @@ -220,6 +226,7 @@ export async function startRuntimeHostDesktopManager( options.upgradePrompts, options.waitForHostExit ?? waitForProcessExit, options.waitForHostRetirement ?? waitForProcessRetirement, + options.recoverManagedLocalHost, options.reconnectBackoff, options.pairingFinalizationTimeoutMs ?? DEFAULT_PAIRING_FINALIZATION_TIMEOUT_MS, options.onTargetStateChanged, @@ -259,6 +266,9 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { registration: HostRegistration, signal: AbortSignal, ) => Promise, + private readonly recoverManagedLocalHost: + | ((signal: AbortSignal) => Promise) + | undefined, private readonly reconnectBackoff: RuntimeHostReconnectBackoff | undefined, private readonly pairingFinalizationTimeoutMs: number, private readonly onTargetStateChanged: @@ -650,13 +660,16 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { if (target.state.readiness !== 'unavailable') return undefined; const last = target.lastCandidate; if (!last || last.ownership !== 'owned_ephemeral') return { kind: 'not_owned' }; + if (last.ownedProcess?.state === 'exited') return { kind: 'not_owned' }; throw new DesktopLocalHostRetirementError( { hostId: last.hostId, hostEpoch: last.hostEpoch, lifecycleMode: 'ephemeral', rootPath: this.#baseInput.rootPath, - ...(last.pid === undefined ? {} : { pid: last.pid }), + ...(last.ownedProcess?.state === 'running' + ? { pid: last.ownedProcess.pid } + : {}), }, { cause: cause instanceof Error ? cause : new Error(String(cause)) }, ); @@ -755,6 +768,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { sshInteraction: RuntimeHostSshInteraction | undefined, ): Promise { let takeoverHostEpoch: string | undefined; + let managedRecoveryAttempted = false; const inheritedExit = target.input.onExit; while (true) { const result = await this.startCandidate( @@ -779,11 +793,24 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { ); if (result.kind === 'ready') { target.hostId = result.candidate.client.hostId; + const previous = target.lastCandidate; + const retainedOwnedProcess = + previous?.hostId === result.candidate.client.hostId && + previous.hostEpoch === result.candidate.client.hostEpoch && + previous.ownership === 'owned_ephemeral' && + result.candidate.hostOwnership === 'owned_ephemeral' && + previous.ownedProcess?.pid === result.candidate.hostPid + ? previous.ownedProcess + : undefined; target.lastCandidate = { hostId: result.candidate.client.hostId, hostEpoch: result.candidate.client.hostEpoch, ownership: result.candidate.hostOwnership, - ...(result.candidate.hostPid === undefined ? {} : { pid: result.candidate.hostPid }), + ...(result.candidate.ownedProcess + ? { ownedProcess: trackOwnedProcess(result.candidate.ownedProcess) } + : retainedOwnedProcess + ? { ownedProcess: retainedOwnedProcess } + : {}), }; return result.candidate; } @@ -817,6 +844,15 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { await this.waitForHostRetirement(result.registration, signal); continue; } + if ( + !target.input.profileTarget && + result.reason === 'managed_root_requires_operator' && + !managedRecoveryAttempted && + this.recoverManagedLocalHost + ) { + managedRecoveryAttempted = true; + if (await this.recoverManagedLocalHost(signal)) continue; + } throw runtimeHostStartupError(result.reason, result.diagnostic); } } @@ -1017,6 +1053,21 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { } } +function trackOwnedProcess( + process: NonNullable, +): DesktopOwnedProcessEvidence { + const evidence: DesktopOwnedProcessEvidence = { pid: process.pid, state: 'running' }; + void process.exited.then( + () => { + evidence.state = 'exited'; + }, + () => { + evidence.state = 'unknown'; + }, + ); + return evidence; +} + function pairingFinalizeRetry(error: unknown): boolean { // Finalization is idempotent for the current credential, so both a known // non-dispatch and an unknown outcome converge on the replacement connection. diff --git a/apps/desktop/src/main/runtime-host-local-remote-access.ts b/apps/desktop/src/main/runtime-host-local-remote-access.ts index 723bada649..20701cf984 100644 --- a/apps/desktop/src/main/runtime-host-local-remote-access.ts +++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts @@ -26,6 +26,7 @@ import { consumeAccessCredentialDelivery, encodeRuntimeHostOwnerConnectionCode, } from '@maka/runtime-host/client'; +import { resolveRuntimeHostManagedDeploymentAuthority } from '@maka/runtime-host/operator'; import { REMOTE_OWNER_OPERATION_GRANTS } from '@maka/runtime-host/protocol'; import type { DesktopLocalRuntimeHostRemoteAccessEnableResult, @@ -110,7 +111,12 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { signal?: AbortSignal, ) => DesktopRuntimeHostSetupPackage | Promise; readonly operator: DesktopRuntimeHostLocalOperator; -}): { recover(): Promise; close(): Promise } { + readonly hasManagedDeploymentAuthority?: (rootId: string) => Promise; +}): { + recoverManagedHostForConnect(signal?: AbortSignal): Promise; + recover(): Promise; + close(): Promise; +} { const lifecyclePath = join(input.clientDataRoot, LIFECYCLE_FILE); const closing = new AbortController(); let mutation = Promise.resolve(); @@ -245,31 +251,47 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { await removeDocument(lifecyclePath); throw new Error('The Local Runtime Host is already managed outside this Desktop'); } - let target: LocalServiceTarget | undefined; try { - const runSetup = () => - input.operator.runSetup( - { - setupPackage, - clientDataRoot: input.clientDataRoot, - rootPath: handoff.rootPath, - principalId: LOCAL_REMOTE_ACCESS_PRINCIPAL_ID, - coordinationRelays: handoff.coordinationRelays, - expectedTarget: { - serviceId: handoff.rootId, - rootPath: handoff.rootPath, - rootId: handoff.rootId, - }, - signal: closing.signal, - }, - () => undefined, - ); + const reconcile = () => reconcileHandoff(handoff, setupPackage); // Recovery may find that the operator already owns the root. Its setup // can still restart that Host, so keep Desktop reconnect quiesced across // the entire reconciliation just like every other managed-service change. - const complete = retirement.kind === 'not_owned' - ? await manager.runManagedLocalHostChange(runSetup) - : await runSetup(); + return retirement.kind === 'not_owned' + ? await manager.runManagedLocalHostChange(reconcile) + : await reconcile(); + } finally { + if (retirement.kind === 'retired') retirement.resume(); + } + }; + + const reconcileHandoff = async ( + handoff: LocalServiceHandoff, + setupPackage: DesktopRuntimeHostSetupPackage, + signal: AbortSignal = closing.signal, + ): Promise<{ + readonly kind: 'complete'; + readonly managed: LocalServiceManaged; + readonly peer: LocalPeerDescriptor; + readonly credential: string; + }> => { + let target: LocalServiceTarget | undefined; + try { + const complete = await input.operator.runSetup( + { + setupPackage, + clientDataRoot: input.clientDataRoot, + rootPath: handoff.rootPath, + principalId: LOCAL_REMOTE_ACCESS_PRINCIPAL_ID, + coordinationRelays: handoff.coordinationRelays, + expectedTarget: { + serviceId: handoff.rootId, + rootPath: handoff.rootPath, + rootId: handoff.rootId, + }, + signal, + }, + () => undefined, + ); if ( complete.serviceId !== handoff.rootId || complete.rootPath !== handoff.rootPath || @@ -291,10 +313,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { handoff.rootPath, ); const peer = requireEnabledPeer({ state: 'enabled', ...complete.directPeer }); - const managed: LocalServiceManaged = { - ...target, - state: 'managed', - }; + const managed: LocalServiceManaged = { ...target, state: 'managed' }; await writeDocument(lifecyclePath, managed); return { kind: 'complete', managed, peer, credential: complete.credential }; } catch (error) { @@ -309,8 +328,6 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { ); } throw error; - } finally { - if (retirement.kind === 'retired') retirement.resume(); } }; @@ -493,6 +510,23 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { input.ipcMain.handle(channels[5], (_event, value: unknown) => uninstall(value)); return { + recoverManagedHostForConnect: (signal) => + serialize(async () => { + if (!supported(input.directPeerAvailable)) return false; + const lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + if (lifecycle?.state !== 'handoff') return false; + const hasAuthority = + input.hasManagedDeploymentAuthority ?? + (async (rootId: string) => + (await resolveRuntimeHostManagedDeploymentAuthority(rootId)) !== undefined); + if (!(await hasAuthority(lifecycle.rootId))) return false; + const operationSignal = signal + ? AbortSignal.any([signal, closing.signal]) + : closing.signal; + const setupPackage = await input.resolveSetupPackage(operationSignal); + await reconcileHandoff(lifecycle, setupPackage, operationSignal); + return true; + }), recover: () => serialize(async () => { if (!supported(input.directPeerAvailable)) return; From fce8fc6d97cb5c7206104db2c38823b2839a6dc5 Mon Sep 17 00:00:00 2001 From: Wang Date: Sat, 29 Aug 2026 17:37:00 +0800 Subject: [PATCH 3/5] fix(desktop): commit managed setup recovery boundary Persist recoverable setup state only after the Desktop-owned Host has retired. Give Local recovery one bounded chance across both returned and thrown discovery failures without interpreting operator-specific errors in the manager. Generated-by: OpenAI Codex --- .../runtime-host-desktop-manager.test.ts | 53 ++++++++- .../runtime-host-local-remote-access.test.ts | 63 +++++++++-- apps/desktop/src/main/runtime-host-boot.ts | 3 +- .../src/main/runtime-host-desktop-manager.ts | 73 +++++++------ .../main/runtime-host-local-remote-access.ts | 102 +++++++++--------- 5 files changed, 201 insertions(+), 93 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts index 69aba09a33..cbc687b668 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts @@ -271,6 +271,33 @@ test('does not retire the local Host twice when an update handoff triggers quit' await owner.close(); }); +test('does not block quit after a retired Local Host hands off to an unavailable supervisor', async () => { + const current = candidateHarness({ disconnectOnPrepare: true }); + let starts = 0; + let reportFatal!: (error: Error) => void; + const fatalReported = new Promise((resolve) => { + reportFatal = resolve; + }); + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => { + starts += 1; + return starts === 1 ? ready(current.candidate) : incompatibleHost('wait_for_idle_exit'); + }, + waitForHostExit: async () => undefined, + onFatalError: reportFatal, + }); + + const handoff = await owner.retireOwnedLocalHost('interrupt_active_work'); + assert.equal(handoff.kind, 'retired'); + if (handoff.kind === 'retired') handoff.resume(); + await fatalReported; + + assert.deepEqual(await owner.retireOwnedLocalHost('interrupt_active_work'), { + kind: 'not_owned', + }); + await owner.close(); +}); + test('coalesces concurrent retirement intents onto one exact Host request', async () => { const current = candidateHarness({ disconnectOnPrepare: true }); let releaseExitWait!: () => void; @@ -895,7 +922,7 @@ test('keeps reconnecting through transient startup failures until the Desktop ad await owner.close(); }); -test('reconciles an interrupted managed handoff before retrying Local discovery', async () => { +test('reconciles interrupted managed setup after a Local discovery result', async () => { const managed = candidateHarness({ ownership: 'supervised' }); const events: string[] = []; let starts = 0; @@ -907,7 +934,29 @@ test('reconciles an interrupted managed handoff before retrying Local discovery' ? { kind: 'failed', reason: 'managed_root_requires_operator' } : ready(managed.candidate); }, - recoverManagedLocalHost: async () => { + recoverLocalHost: async () => { + events.push('reconcile'); + return true; + }, + }); + + assert.deepEqual(events, ['discover:1', 'reconcile', 'discover:2']); + assert.equal(owner.current('local')?.candidate?.hostOwnership, 'supervised'); + await owner.close(); +}); + +test('reconciles interrupted managed setup after Local discovery throws', async () => { + const managed = candidateHarness({ ownership: 'supervised' }); + const events: string[] = []; + let starts = 0; + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => { + starts += 1; + events.push(`discover:${starts}`); + if (starts === 1) throw new Error('managed deployment transition is in progress'); + return ready(managed.candidate); + }, + recoverLocalHost: async () => { events.push('reconcile'); return true; }, diff --git a/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts index a40c2e6801..3ac3894dab 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts @@ -198,7 +198,58 @@ test('revokes the one Local sharing authority without changing peer connectivity ]); }); -test('reconciles a committed handoff when Local discovery finds a managed root gap', async (t) => { +test('does not persist recoverable setup authority before Desktop ownership commits', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-local-remote-access-ownership-')); + t.after(() => rm(base, { recursive: true, force: true })); + const clientDataRoot = join(base, 'client'); + const rootPath = join(clientDataRoot, 'workspaces', 'default'); + const rootId = 'a'.repeat(64); + const lifecyclePath = join(clientDataRoot, 'runtime-host-local-service.json'); + await mkdir(rootPath, { recursive: true }); + const handlers = new Map[1]>(); + let ownershipChecked = false; + let setupCalls = 0; + const service = createDesktopLocalRuntimeHostRemoteAccess({ + ipcMain: { + handle: (channel, handler) => { handlers.set(channel, handler); }, + removeHandler: (channel) => { handlers.delete(channel); }, + }, + clientDataRoot, + rootPath, + rootId, + directPeerAvailable: true, + manager: () => + ({ + async retireOwnedLocalHost() { + ownershipChecked = true; + await assert.rejects(readFile(lifecyclePath, 'utf8'), { code: 'ENOENT' }); + return { kind: 'not_owned' as const }; + }, + }) as unknown as RuntimeHostDesktopManager, + resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), + operator: { + async runSetup() { + setupCalls += 1; + throw new Error('setup must not run for an externally managed Host'); + }, + async close() {}, + } as unknown as ReturnType, + }); + t.after(() => service.close()); + + const enable = handlers.get('local-runtime-host-remote-access:enable'); + assert.ok(enable); + const enabling = enable({} as Electron.IpcMainInvokeEvent, { + allowInterruptActiveTasks: false, + coordinationRelays: [], + }); + await assert.rejects(enabling, /already managed outside this Desktop/u); + assert.equal(ownershipChecked, true); + await assert.rejects(readFile(lifecyclePath, 'utf8'), { code: 'ENOENT' }); + assert.equal(setupCalls, 0); +}); + +test('reconciles committed managed setup when Local discovery fails', async (t) => { const base = await mkdtemp(join(tmpdir(), 'maka-local-remote-access-prestart-')); t.after(() => rm(base, { recursive: true, force: true })); const clientDataRoot = join(base, 'client'); @@ -209,7 +260,7 @@ test('reconciles a committed handoff when Local discovery finds a managed root g join(clientDataRoot, 'runtime-host-local-service.json'), `${JSON.stringify({ schemaVersion: 1, - state: 'handoff', + state: 'setupPending', rootPath, rootId, coordinationRelays: [], @@ -248,7 +299,7 @@ test('reconciles a committed handoff when Local discovery finds a managed root g }); t.after(() => service.close()); - assert.equal(await service.recoverManagedHostForConnect(), true); + assert.equal(await service.recoverManagedSetup(), true); assert.equal(setupCalls, 1); assert.equal( @@ -258,7 +309,7 @@ test('reconciles a committed handoff when Local discovery finds a managed root g ); }); -test('an interrupted Local Host handoff converges to its exact managed service', async (t) => { +test('interrupted Local Host setup converges to its exact managed service', async (t) => { const base = await mkdtemp(join(tmpdir(), 'maka-local-remote-access-recovery-')); t.after(() => rm(base, { recursive: true, force: true })); const clientDataRoot = join(base, 'client'); @@ -269,7 +320,7 @@ test('an interrupted Local Host handoff converges to its exact managed service', join(clientDataRoot, 'runtime-host-local-service.json'), `${JSON.stringify({ schemaVersion: 1, - state: 'handoff', + state: 'setupPending', rootPath, rootId, coordinationRelays: [], @@ -319,7 +370,7 @@ test('an interrupted Local Host handoff converges to its exact managed service', }); t.after(() => service.close()); - assert.equal(await service.recoverManagedHostForConnect(), false); + assert.equal(await service.recoverManagedSetup(), false); assert.equal(setupCalls, 0); await service.recover(); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index d9fa8a68d0..8a73c0be66 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -991,8 +991,7 @@ runtimeHostManager = await startRuntimeHostDesktopManager( isDefault: true, }); }, - recoverManagedLocalHost: (signal) => - localRuntimeHostRemoteAccess.recoverManagedHostForConnect(signal), + recoverLocalHost: (signal) => localRuntimeHostRemoteAccess.recoverManagedSetup(signal), onFatalError: (error, target) => { if (error instanceof RuntimeHostUpgradeCancelledError) { if (target.profile.kind === "local") app.quit(); diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index 380451aac9..e50c03e4de 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -210,7 +210,7 @@ export async function startRuntimeHostDesktopManager( registration: HostRegistration, signal: AbortSignal, ) => Promise; - recoverManagedLocalHost?: (signal: AbortSignal) => Promise; + recoverLocalHost?: (signal: AbortSignal) => Promise; reconnectBackoff?: RuntimeHostReconnectBackoff; pairingFinalizationTimeoutMs?: number; onTargetStateChanged?: (state: RuntimeHostDesktopTargetState) => void; @@ -226,7 +226,7 @@ export async function startRuntimeHostDesktopManager( options.upgradePrompts, options.waitForHostExit ?? waitForProcessExit, options.waitForHostRetirement ?? waitForProcessRetirement, - options.recoverManagedLocalHost, + options.recoverLocalHost, options.reconnectBackoff, options.pairingFinalizationTimeoutMs ?? DEFAULT_PAIRING_FINALIZATION_TIMEOUT_MS, options.onTargetStateChanged, @@ -266,7 +266,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { registration: HostRegistration, signal: AbortSignal, ) => Promise, - private readonly recoverManagedLocalHost: + private readonly recoverLocalHost: | ((signal: AbortSignal) => Promise) | undefined, private readonly reconnectBackoff: RuntimeHostReconnectBackoff | undefined, @@ -637,6 +637,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { return result; } await this.waitForHostExit(result.pid); + target.lastCandidate = undefined; return this.#completeLocalHostRetirement(resume); } catch (error) { resume(); @@ -768,29 +769,43 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { sshInteraction: RuntimeHostSshInteraction | undefined, ): Promise { let takeoverHostEpoch: string | undefined; - let managedRecoveryAttempted = false; + let localRecoveryAttempted = false; const inheritedExit = target.input.onExit; + const tryRecoverLocalHost = async (): Promise => { + if (target.input.profileTarget || localRecoveryAttempted || !this.recoverLocalHost) { + return false; + } + localRecoveryAttempted = true; + return this.recoverLocalHost(signal); + }; while (true) { - const result = await this.startCandidate( - { - ...target.input, - onExit: (details) => this.#reportCandidateExit(inheritedExit, details), - ...(target.input.profileTarget - ? { - profileTarget: { - ...target.input.profileTarget, - ...(sshInteraction === undefined ? {} : { sshInteraction }), - }, - } - : {}), - ipcMain: this.#ipcMain.createTarget(target.epoch), - isTargetActive: () => this.#ipcMain.isActive(target.epoch), - isTargetValid: () => target.valid, - signal, - ...(takeoverHostEpoch === undefined ? {} : { takeoverHostEpoch }), - }, - target.observations, - ); + let result: DesktopRuntimeHostCandidateStartResult; + try { + result = await this.startCandidate( + { + ...target.input, + onExit: (details) => this.#reportCandidateExit(inheritedExit, details), + ...(target.input.profileTarget + ? { + profileTarget: { + ...target.input.profileTarget, + ...(sshInteraction === undefined ? {} : { sshInteraction }), + }, + } + : {}), + ipcMain: this.#ipcMain.createTarget(target.epoch), + isTargetActive: () => this.#ipcMain.isActive(target.epoch), + isTargetValid: () => target.valid, + signal, + ...(takeoverHostEpoch === undefined ? {} : { takeoverHostEpoch }), + }, + target.observations, + ); + } catch (error) { + signal.throwIfAborted(); + if (await tryRecoverLocalHost()) continue; + throw error; + } if (result.kind === 'ready') { target.hostId = result.candidate.client.hostId; const previous = target.lastCandidate; @@ -844,15 +859,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { await this.waitForHostRetirement(result.registration, signal); continue; } - if ( - !target.input.profileTarget && - result.reason === 'managed_root_requires_operator' && - !managedRecoveryAttempted && - this.recoverManagedLocalHost - ) { - managedRecoveryAttempted = true; - if (await this.recoverManagedLocalHost(signal)) continue; - } + if (await tryRecoverLocalHost()) continue; throw runtimeHostStartupError(result.reason, result.diagnostic); } } diff --git a/apps/desktop/src/main/runtime-host-local-remote-access.ts b/apps/desktop/src/main/runtime-host-local-remote-access.ts index 20701cf984..31632eabaf 100644 --- a/apps/desktop/src/main/runtime-host-local-remote-access.ts +++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts @@ -55,9 +55,10 @@ interface LocalServiceTarget extends DesktopRuntimeHostLocalServiceTarget { readonly deploymentId: string; } -interface LocalServiceHandoff { +interface LocalServiceSetupPending { readonly schemaVersion: 1; - readonly state: 'handoff'; + /** Persisted only after the Desktop-owned Host has retired. */ + readonly state: 'setupPending'; readonly rootPath: string; readonly rootId: string; readonly coordinationRelays: readonly string[]; @@ -81,7 +82,7 @@ interface LocalServiceUninstalling extends LocalServiceTarget { } type LocalServiceLifecycle = - | LocalServiceHandoff + | LocalServiceSetupPending | LocalServiceManaged | LocalServicePeerChanging | LocalServiceUninstalling; @@ -113,7 +114,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { readonly operator: DesktopRuntimeHostLocalOperator; readonly hasManagedDeploymentAuthority?: (rootId: string) => Promise; }): { - recoverManagedHostForConnect(signal?: AbortSignal): Promise; + recoverManagedSetup(signal?: AbortSignal): Promise; recover(): Promise; close(): Promise; } { @@ -128,6 +129,10 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { ); return result; }; + const hasManagedDeploymentAuthority = + input.hasManagedDeploymentAuthority ?? + (async (rootId: string) => + (await resolveRuntimeHostManagedDeploymentAuthority(rootId)) !== undefined); const getSnapshot = (): Promise => serialize(async () => { @@ -139,7 +144,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { return { state: 'unavailable', message: - lifecycle.state === 'handoff' + lifecycle.state === 'setupPending' ? 'Local Runtime Host setup is being recovered' : lifecycle.state === 'peerChanging' ? 'Local Runtime Host remote access is being recovered' @@ -175,8 +180,8 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { if (recovered.kind === 'active_tasks') return recovered; lifecycle = managedLifecycle(lifecycle); } - if (lifecycle?.state === 'handoff') { - const recovered = await finishHandoff(lifecycle, true); + if (lifecycle?.state === 'setupPending') { + const recovered = await finishSetup(lifecycle, 'recovery'); if (recovered.kind === 'active_tasks') return recovered; lifecycle = recovered.managed; } @@ -205,16 +210,15 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { ); } - const handoff: LocalServiceHandoff = { + const setup: LocalServiceSetupPending = { schemaVersion: 1, - state: 'handoff', + state: 'setupPending', rootPath: input.rootPath, rootId: input.rootId, coordinationRelays: request.coordinationRelays, allowInterruptActiveTasks: request.allowInterruptActiveTasks, }; - await writeDocument(lifecyclePath, handoff); - const completed = await finishHandoff(handoff, false); + const completed = await finishSetup(setup, 'request'); if (completed.kind === 'active_tasks') return completed; return enabledResult( encodeRuntimeHostOwnerConnectionCode({ @@ -226,9 +230,9 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { ); }); - const finishHandoff = async ( - handoff: LocalServiceHandoff, - allowAlreadyManaged: boolean, + const finishSetup = async ( + setup: LocalServiceSetupPending, + origin: 'request' | 'recovery', ): Promise< | { readonly kind: 'active_tasks' } | { @@ -241,18 +245,17 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { const setupPackage = await input.resolveSetupPackage(closing.signal); const manager = requireManager(input.manager); const retirement = await manager.retireOwnedLocalHost( - handoff.allowInterruptActiveTasks ? 'interrupt_active_work' : 'refuse_active_work', + setup.allowInterruptActiveTasks ? 'interrupt_active_work' : 'refuse_active_work', ); if (retirement.kind === 'active_tasks') { - if (!allowAlreadyManaged) await removeDocument(lifecyclePath); return { kind: 'active_tasks' }; } - if (retirement.kind === 'not_owned' && !allowAlreadyManaged) { - await removeDocument(lifecyclePath); + if (retirement.kind === 'not_owned' && origin === 'request') { throw new Error('The Local Runtime Host is already managed outside this Desktop'); } try { - const reconcile = () => reconcileHandoff(handoff, setupPackage); + if (origin === 'request') await writeDocument(lifecyclePath, setup); + const reconcile = () => reconcileSetup(setup, setupPackage); // Recovery may find that the operator already owns the root. Its setup // can still restart that Host, so keep Desktop reconnect quiesced across // the entire reconciliation just like every other managed-service change. @@ -264,8 +267,8 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { } }; - const reconcileHandoff = async ( - handoff: LocalServiceHandoff, + const reconcileSetup = async ( + setup: LocalServiceSetupPending, setupPackage: DesktopRuntimeHostSetupPackage, signal: AbortSignal = closing.signal, ): Promise<{ @@ -280,22 +283,22 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { { setupPackage, clientDataRoot: input.clientDataRoot, - rootPath: handoff.rootPath, + rootPath: setup.rootPath, principalId: LOCAL_REMOTE_ACCESS_PRINCIPAL_ID, - coordinationRelays: handoff.coordinationRelays, + coordinationRelays: setup.coordinationRelays, expectedTarget: { - serviceId: handoff.rootId, - rootPath: handoff.rootPath, - rootId: handoff.rootId, + serviceId: setup.rootId, + rootPath: setup.rootPath, + rootId: setup.rootId, }, signal, }, () => undefined, ); if ( - complete.serviceId !== handoff.rootId || - complete.rootPath !== handoff.rootPath || - complete.rootId !== handoff.rootId || + complete.serviceId !== setup.rootId || + complete.rootPath !== setup.rootPath || + complete.rootId !== setup.rootId || !DEPLOYMENT_ID_PATTERN.test(complete.deploymentId) || !complete.directPeer ) { @@ -310,7 +313,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { rootId: complete.rootId, deploymentId: complete.deploymentId, }, - handoff.rootPath, + setup.rootPath, ); const peer = requireEnabledPeer({ state: 'enabled', ...complete.directPeer }); const managed: LocalServiceManaged = { ...target, state: 'managed' }; @@ -510,30 +513,29 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { input.ipcMain.handle(channels[5], (_event, value: unknown) => uninstall(value)); return { - recoverManagedHostForConnect: (signal) => - serialize(async () => { - if (!supported(input.directPeerAvailable)) return false; - const lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); - if (lifecycle?.state !== 'handoff') return false; - const hasAuthority = - input.hasManagedDeploymentAuthority ?? - (async (rootId: string) => - (await resolveRuntimeHostManagedDeploymentAuthority(rootId)) !== undefined); - if (!(await hasAuthority(lifecycle.rootId))) return false; - const operationSignal = signal - ? AbortSignal.any([signal, closing.signal]) - : closing.signal; + recoverManagedSetup: async (signal) => { + if (!supported(input.directPeerAvailable)) return false; + const operationSignal = signal ? AbortSignal.any([signal, closing.signal]) : closing.signal; + operationSignal.throwIfAborted(); + const observed = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + if (observed?.state !== 'setupPending') return false; + return serialize(async () => { + operationSignal.throwIfAborted(); + const pending = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + if (pending?.state !== 'setupPending') return false; + if (!(await hasManagedDeploymentAuthority(pending.rootId))) return false; const setupPackage = await input.resolveSetupPackage(operationSignal); - await reconcileHandoff(lifecycle, setupPackage, operationSignal); + await reconcileSetup(pending, setupPackage, operationSignal); return true; - }), + }); + }, recover: () => serialize(async () => { if (!supported(input.directPeerAvailable)) return; const lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); if (!lifecycle) return; - if (lifecycle.state === 'handoff') { - await finishHandoff(lifecycle, true); + if (lifecycle.state === 'setupPending') { + await finishSetup(lifecycle, 'recovery'); return; } if (lifecycle.state === 'uninstalling') { @@ -767,7 +769,7 @@ async function readLifecycle( ) { throw new Error('Local Runtime Host service lifecycle is invalid'); } - if (value.state === 'handoff') { + if (value.state === 'setupPending') { assertExactKeys(value, [ 'schemaVersion', 'state', @@ -779,11 +781,11 @@ async function readLifecycle( if ( typeof value.allowInterruptActiveTasks !== 'boolean' ) { - throw new Error('Local Runtime Host handoff intent is invalid'); + throw new Error('Local Runtime Host pending setup is invalid'); } return { schemaVersion: 1, - state: 'handoff', + state: 'setupPending', rootPath, rootId, coordinationRelays: requireAddresses(value.coordinationRelays), From 5cfff241cecfbc0f8e466b6dfce89633cfb672a5 Mon Sep 17 00:00:00 2001 From: Wang Date: Sat, 29 Aug 2026 18:03:16 +0800 Subject: [PATCH 4/5] fix(desktop): migrate legacy local setup receipts Generated-by: OpenAI Codex --- .../runtime-host-local-remote-access.test.ts | 55 ++++++++++- .../main/runtime-host-local-remote-access.ts | 95 +++++++++++++++++-- 2 files changed, 141 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts index 3ac3894dab..fbff315baf 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts @@ -249,7 +249,7 @@ test('does not persist recoverable setup authority before Desktop ownership comm assert.equal(setupCalls, 0); }); -test('reconciles committed managed setup when Local discovery fails', async (t) => { +test('migrates a legacy handoff after managed setup committed', async (t) => { const base = await mkdtemp(join(tmpdir(), 'maka-local-remote-access-prestart-')); t.after(() => rm(base, { recursive: true, force: true })); const clientDataRoot = join(base, 'client'); @@ -260,7 +260,7 @@ test('reconciles committed managed setup when Local discovery fails', async (t) join(clientDataRoot, 'runtime-host-local-service.json'), `${JSON.stringify({ schemaVersion: 1, - state: 'setupPending', + state: 'handoff', rootPath, rootId, coordinationRelays: [], @@ -309,6 +309,57 @@ test('reconciles committed managed setup when Local discovery fails', async (t) ); }); +test('discards a legacy handoff that belongs to an externally managed Host', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-local-remote-access-legacy-external-')); + t.after(() => rm(base, { recursive: true, force: true })); + const clientDataRoot = join(base, 'client'); + const rootPath = join(clientDataRoot, 'workspaces', 'default'); + const rootId = 'a'.repeat(64); + const lifecyclePath = join(clientDataRoot, 'runtime-host-local-service.json'); + await mkdir(rootPath, { recursive: true }); + await writeFile( + lifecyclePath, + `${JSON.stringify({ + schemaVersion: 1, + state: 'handoff', + rootPath, + rootId, + coordinationRelays: [], + allowInterruptActiveTasks: false, + })}\n`, + ); + let setupCalls = 0; + const service = createDesktopLocalRuntimeHostRemoteAccess({ + ipcMain: { handle() {}, removeHandler() {} }, + clientDataRoot, + rootPath, + rootId, + directPeerAvailable: true, + manager: () => + ({ + async retireOwnedLocalHost() { + return { kind: 'not_owned' as const }; + }, + }) as unknown as RuntimeHostDesktopManager, + hasManagedDeploymentAuthority: async () => false, + resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), + operator: { + async runSetup() { + setupCalls += 1; + throw new Error('setup must not replace an externally managed Host'); + }, + async close() {}, + } as unknown as ReturnType, + }); + t.after(() => service.close()); + + assert.equal(await service.recoverManagedSetup(), false); + await service.recover(); + + assert.equal(setupCalls, 0); + await assert.rejects(readFile(lifecyclePath, 'utf8'), { code: 'ENOENT' }); +}); + test('interrupted Local Host setup converges to its exact managed service', async (t) => { const base = await mkdtemp(join(tmpdir(), 'maka-local-remote-access-recovery-')); t.after(() => rm(base, { recursive: true, force: true })); diff --git a/apps/desktop/src/main/runtime-host-local-remote-access.ts b/apps/desktop/src/main/runtime-host-local-remote-access.ts index 31632eabaf..ff16e391b1 100644 --- a/apps/desktop/src/main/runtime-host-local-remote-access.ts +++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts @@ -65,6 +65,16 @@ interface LocalServiceSetupPending { readonly allowInterruptActiveTasks: boolean; } +/** Schema-v1 setup intent written by Desktop releases before ownership was established. */ +interface LocalServiceLegacyHandoff { + readonly schemaVersion: 1; + readonly state: 'handoff'; + readonly rootPath: string; + readonly rootId: string; + readonly coordinationRelays: readonly string[]; + readonly allowInterruptActiveTasks: boolean; +} + interface LocalServiceManaged extends LocalServiceTarget { readonly state: 'managed'; } @@ -82,6 +92,7 @@ interface LocalServiceUninstalling extends LocalServiceTarget { } type LocalServiceLifecycle = + | LocalServiceLegacyHandoff | LocalServiceSetupPending | LocalServiceManaged | LocalServicePeerChanging @@ -144,7 +155,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { return { state: 'unavailable', message: - lifecycle.state === 'setupPending' + lifecycle.state === 'setupPending' || lifecycle.state === 'handoff' ? 'Local Runtime Host setup is being recovered' : lifecycle.state === 'peerChanging' ? 'Local Runtime Host remote access is being recovered' @@ -180,6 +191,14 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { if (recovered.kind === 'active_tasks') return recovered; lifecycle = managedLifecycle(lifecycle); } + if (lifecycle?.state === 'handoff') { + const recovered = await recoverLegacyHandoff(lifecycle); + if (recovered.kind === 'active_tasks') return recovered; + if (recovered.kind === 'external') { + throw new Error('The Local Runtime Host is already managed outside this Desktop'); + } + lifecycle = recovered.managed; + } if (lifecycle?.state === 'setupPending') { const recovered = await finishSetup(lifecycle, 'recovery'); if (recovered.kind === 'active_tasks') return recovered; @@ -334,6 +353,49 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { } }; + const recoverLegacyHandoff = async ( + legacy: LocalServiceLegacyHandoff, + ): Promise< + | { readonly kind: 'active_tasks' } + | { readonly kind: 'external' } + | { + readonly kind: 'complete'; + readonly managed: LocalServiceManaged; + readonly peer: LocalPeerDescriptor; + readonly credential: string; + } + > => { + const pending = pendingSetup(legacy); + if (await hasManagedDeploymentAuthority(legacy.rootId)) { + await writeDocument(lifecyclePath, pending); + return finishSetup(pending, 'recovery'); + } + + const manager = requireManager(input.manager); + const retirement = await manager.retireOwnedLocalHost( + legacy.allowInterruptActiveTasks ? 'interrupt_active_work' : 'refuse_active_work', + ); + if (retirement.kind === 'active_tasks') return { kind: 'active_tasks' }; + if ( + retirement.kind === 'not_owned' && + !(await hasManagedDeploymentAuthority(legacy.rootId)) + ) { + await removeDocument(lifecyclePath); + return { kind: 'external' }; + } + + try { + await writeDocument(lifecyclePath, pending); + const setupPackage = await input.resolveSetupPackage(closing.signal); + const reconcile = () => reconcileSetup(pending, setupPackage); + return retirement.kind === 'not_owned' + ? await manager.runManagedLocalHostChange(reconcile) + : await reconcile(); + } finally { + if (retirement.kind === 'retired') retirement.resume(); + } + }; + const createConnectionCode = (): Promise => serialize(async () => { const managed = requireManaged( @@ -518,14 +580,16 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { const operationSignal = signal ? AbortSignal.any([signal, closing.signal]) : closing.signal; operationSignal.throwIfAborted(); const observed = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); - if (observed?.state !== 'setupPending') return false; + if (observed?.state !== 'setupPending' && observed?.state !== 'handoff') return false; return serialize(async () => { operationSignal.throwIfAborted(); const pending = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); - if (pending?.state !== 'setupPending') return false; + if (pending?.state !== 'setupPending' && pending?.state !== 'handoff') return false; if (!(await hasManagedDeploymentAuthority(pending.rootId))) return false; + const committed = pendingSetup(pending); + if (pending.state === 'handoff') await writeDocument(lifecyclePath, committed); const setupPackage = await input.resolveSetupPackage(operationSignal); - await reconcileSetup(pending, setupPackage, operationSignal); + await reconcileSetup(committed, setupPackage, operationSignal); return true; }); }, @@ -534,6 +598,10 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { if (!supported(input.directPeerAvailable)) return; const lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); if (!lifecycle) return; + if (lifecycle.state === 'handoff') { + await recoverLegacyHandoff(lifecycle); + return; + } if (lifecycle.state === 'setupPending') { await finishSetup(lifecycle, 'recovery'); return; @@ -737,6 +805,19 @@ function requireManaged(lifecycle: LocalServiceLifecycle | undefined): LocalServ return lifecycle; } +function pendingSetup( + intent: LocalServiceSetupPending | LocalServiceLegacyHandoff, +): LocalServiceSetupPending { + return { + schemaVersion: 1, + state: 'setupPending', + rootPath: intent.rootPath, + rootId: intent.rootId, + coordinationRelays: intent.coordinationRelays, + allowInterruptActiveTasks: intent.allowInterruptActiveTasks, + }; +} + function managedLifecycle(intent: LocalServiceTarget): LocalServiceManaged { return { schemaVersion: 1, @@ -769,7 +850,7 @@ async function readLifecycle( ) { throw new Error('Local Runtime Host service lifecycle is invalid'); } - if (value.state === 'setupPending') { + if (value.state === 'setupPending' || value.state === 'handoff') { assertExactKeys(value, [ 'schemaVersion', 'state', @@ -781,11 +862,11 @@ async function readLifecycle( if ( typeof value.allowInterruptActiveTasks !== 'boolean' ) { - throw new Error('Local Runtime Host pending setup is invalid'); + throw new Error('Local Runtime Host setup intent is invalid'); } return { schemaVersion: 1, - state: 'setupPending', + state: value.state, rootPath, rootId, coordinationRelays: requireAddresses(value.coordinationRelays), From 5ce6f1a37ccb98eb3edb4d70839f2e3c33a9a30f Mon Sep 17 00:00:00 2001 From: Wang Date: Sat, 29 Aug 2026 18:23:06 +0800 Subject: [PATCH 5/5] fix(desktop): adopt committed local Host setup Recover pending Desktop setup receipts from the active managed deployment authority instead of replaying package setup. This keeps recovery valid across Desktop package updates and preserves the explicit update workflow. Generated-by: OpenAI Codex --- .../runtime-host-local-remote-access.test.ts | 110 +++++++++--------- .../main/runtime-host-local-remote-access.ts | 93 +++++++++++---- 2 files changed, 128 insertions(+), 75 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts index fbff315baf..1158c7c458 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts @@ -249,64 +249,70 @@ test('does not persist recoverable setup authority before Desktop ownership comm assert.equal(setupCalls, 0); }); -test('migrates a legacy handoff after managed setup committed', async (t) => { +test('adopts committed managed authority for every pending receipt without replaying setup', async (t) => { const base = await mkdtemp(join(tmpdir(), 'maka-local-remote-access-prestart-')); t.after(() => rm(base, { recursive: true, force: true })); - const clientDataRoot = join(base, 'client'); - const rootPath = join(clientDataRoot, 'workspaces', 'default'); - const rootId = 'a'.repeat(64); - await mkdir(rootPath, { recursive: true }); - await writeFile( - join(clientDataRoot, 'runtime-host-local-service.json'), - `${JSON.stringify({ - schemaVersion: 1, - state: 'handoff', + for (const state of ['handoff', 'setupPending'] as const) { + const clientDataRoot = join(base, state); + const rootPath = join(clientDataRoot, 'workspaces', 'default'); + const rootId = 'a'.repeat(64); + const deploymentId = '22222222-2222-4222-8222-222222222222'; + const operatorPath = join(base, 'installed', 'operator'); + await mkdir(rootPath, { recursive: true }); + await writeFile( + join(clientDataRoot, 'runtime-host-local-service.json'), + `${JSON.stringify({ + schemaVersion: 1, + state, + rootPath, + rootId, + coordinationRelays: [], + allowInterruptActiveTasks: true, + })}\n`, + ); + const service = createDesktopLocalRuntimeHostRemoteAccess({ + ipcMain: { handle() {}, removeHandler() {} }, + clientDataRoot, rootPath, rootId, - coordinationRelays: [], - allowInterruptActiveTasks: true, - })}\n`, - ); - let setupCalls = 0; - const service = createDesktopLocalRuntimeHostRemoteAccess({ - ipcMain: { handle() {}, removeHandler() {} }, - clientDataRoot, - rootPath, - rootId, - directPeerAvailable: true, - manager: () => assert.fail('pre-start reconciliation must not require the Local manager'), - hasManagedDeploymentAuthority: async () => true, - resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), - operator: { - async runSetup() { - setupCalls += 1; - return { + directPeerAvailable: true, + manager: () => assert.fail('pre-start reconciliation must not require the Local manager'), + resolveManagedDeploymentAuthority: async () => ({ + kind: 'active', + target: { + schemaVersion: 1, serviceId: rootId, - operatorPath: join(base, 'operator'), + operatorPath, rootPath, rootId, - deploymentId: '22222222-2222-4222-8222-222222222222', - credential: 'unused-pending-credential', - directPeer: { - peerId: '12D3KooWpeer', - routeHints: ['/ip4/192.0.2.1/udp/41000/quic-v1'], - coordinationRelays: [], - }, - }; - }, - async close() {}, - } as unknown as ReturnType, - }); - t.after(() => service.close()); - - assert.equal(await service.recoverManagedSetup(), true); + deploymentId, + }, + }), + resolveSetupPackage: async () => + assert.fail('committed authority must not resolve a package'), + operator: { + async runSetup() { + assert.fail('committed authority must not replay setup'); + }, + async close() {}, + } as unknown as ReturnType, + }); + t.after(() => service.close()); - assert.equal(setupCalls, 1); - assert.equal( - JSON.parse(await readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8')) - .state, - 'managed', - ); + assert.equal(await service.recoverManagedSetup(), true); + assert.deepEqual( + JSON.parse(await readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8')), + { + schemaVersion: 1, + state: 'managed', + serviceId: rootId, + operatorPath, + rootPath, + rootId, + deploymentId, + }, + ); + } }); test('discards a legacy handoff that belongs to an externally managed Host', async (t) => { @@ -341,7 +347,7 @@ test('discards a legacy handoff that belongs to an externally managed Host', asy return { kind: 'not_owned' as const }; }, }) as unknown as RuntimeHostDesktopManager, - hasManagedDeploymentAuthority: async () => false, + resolveManagedDeploymentAuthority: async () => undefined, resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), operator: { async runSetup() { @@ -397,7 +403,7 @@ test('interrupted Local Host setup converges to its exact managed service', asyn return change(); }, }) as unknown as RuntimeHostDesktopManager, - hasManagedDeploymentAuthority: async () => false, + resolveManagedDeploymentAuthority: async () => undefined, resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), operator: { async runSetup() { diff --git a/apps/desktop/src/main/runtime-host-local-remote-access.ts b/apps/desktop/src/main/runtime-host-local-remote-access.ts index ff16e391b1..ab6659d08d 100644 --- a/apps/desktop/src/main/runtime-host-local-remote-access.ts +++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts @@ -79,6 +79,10 @@ interface LocalServiceManaged extends LocalServiceTarget { readonly state: 'managed'; } +type LocalManagedDeploymentAuthority = + | { readonly kind: 'active'; readonly target: LocalServiceTarget } + | { readonly kind: 'transition' }; + interface LocalServicePeerChanging extends LocalServiceTarget { readonly state: 'peerChanging'; readonly peerEnabled: boolean; @@ -123,7 +127,9 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { signal?: AbortSignal, ) => DesktopRuntimeHostSetupPackage | Promise; readonly operator: DesktopRuntimeHostLocalOperator; - readonly hasManagedDeploymentAuthority?: (rootId: string) => Promise; + readonly resolveManagedDeploymentAuthority?: ( + rootId: string, + ) => Promise; }): { recoverManagedSetup(signal?: AbortSignal): Promise; recover(): Promise; @@ -140,10 +146,41 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { ); return result; }; - const hasManagedDeploymentAuthority = - input.hasManagedDeploymentAuthority ?? - (async (rootId: string) => - (await resolveRuntimeHostManagedDeploymentAuthority(rootId)) !== undefined); + const resolveManagedDeploymentAuthority = + input.resolveManagedDeploymentAuthority ?? + (async (rootId: string): Promise => { + const authority = await resolveRuntimeHostManagedDeploymentAuthority(rootId); + if (!authority) return undefined; + if (authority.record.state !== 'active') return { kind: 'transition' }; + return { + kind: 'active', + target: requireServiceTarget( + { + schemaVersion: 1, + serviceId: authority.record.root.id, + operatorPath: join(authority.record.deploymentRoot, 'operator'), + rootPath: authority.record.root.path, + rootId: authority.record.root.id, + deploymentId: authority.record.deploymentId, + }, + input.rootPath, + ), + }; + }); + + const adoptCommittedSetup = async ( + setup: LocalServiceSetupPending, + ): Promise< + | { readonly kind: 'absent' | 'transition' } + | { readonly kind: 'managed'; readonly managed: LocalServiceManaged } + > => { + const authority = await resolveManagedDeploymentAuthority(setup.rootId); + if (!authority) return { kind: 'absent' }; + if (authority.kind === 'transition') return authority; + const managed = managedLifecycle(authority.target); + await writeDocument(lifecyclePath, managed); + return { kind: 'managed', managed }; + }; const getSnapshot = (): Promise => serialize(async () => { @@ -200,9 +237,14 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { lifecycle = recovered.managed; } if (lifecycle?.state === 'setupPending') { - const recovered = await finishSetup(lifecycle, 'recovery'); - if (recovered.kind === 'active_tasks') return recovered; - lifecycle = recovered.managed; + const committed = await adoptCommittedSetup(lifecycle); + if (committed.kind === 'managed') { + lifecycle = committed.managed; + } else { + const recovered = await finishSetup(lifecycle, 'recovery'); + if (recovered.kind === 'active_tasks') return recovered; + lifecycle = recovered.managed; + } } if (lifecycle?.state === 'managed') { const manager = requireManager(input.manager); @@ -358,15 +400,14 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { ): Promise< | { readonly kind: 'active_tasks' } | { readonly kind: 'external' } - | { - readonly kind: 'complete'; - readonly managed: LocalServiceManaged; - readonly peer: LocalPeerDescriptor; - readonly credential: string; - } + | { readonly kind: 'complete'; readonly managed: LocalServiceManaged } > => { const pending = pendingSetup(legacy); - if (await hasManagedDeploymentAuthority(legacy.rootId)) { + const authority = await adoptCommittedSetup(pending); + if (authority.kind === 'managed') { + return { kind: 'complete', managed: authority.managed }; + } + if (authority.kind === 'transition') { await writeDocument(lifecyclePath, pending); return finishSetup(pending, 'recovery'); } @@ -376,12 +417,15 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { legacy.allowInterruptActiveTasks ? 'interrupt_active_work' : 'refuse_active_work', ); if (retirement.kind === 'active_tasks') return { kind: 'active_tasks' }; - if ( - retirement.kind === 'not_owned' && - !(await hasManagedDeploymentAuthority(legacy.rootId)) - ) { - await removeDocument(lifecyclePath); - return { kind: 'external' }; + if (retirement.kind === 'not_owned') { + const raced = await adoptCommittedSetup(pending); + if (raced.kind === 'managed') { + return { kind: 'complete', managed: raced.managed }; + } + if (raced.kind === 'absent') { + await removeDocument(lifecyclePath); + return { kind: 'external' }; + } } try { @@ -585,8 +629,10 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { operationSignal.throwIfAborted(); const pending = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); if (pending?.state !== 'setupPending' && pending?.state !== 'handoff') return false; - if (!(await hasManagedDeploymentAuthority(pending.rootId))) return false; const committed = pendingSetup(pending); + const authority = await adoptCommittedSetup(committed); + if (authority.kind === 'absent') return false; + if (authority.kind === 'managed') return true; if (pending.state === 'handoff') await writeDocument(lifecyclePath, committed); const setupPackage = await input.resolveSetupPackage(operationSignal); await reconcileSetup(committed, setupPackage, operationSignal); @@ -603,7 +649,8 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { return; } if (lifecycle.state === 'setupPending') { - await finishSetup(lifecycle, 'recovery'); + const committed = await adoptCommittedSetup(lifecycle); + if (committed.kind !== 'managed') await finishSetup(lifecycle, 'recovery'); return; } if (lifecycle.state === 'uninstalling') {