From 2e956050de27bf9e32fe0f7a84d0c77f15bd45ad Mon Sep 17 00:00:00 2001 From: Wang Date: Sat, 29 Aug 2026 22:49:22 +0800 Subject: [PATCH 1/6] fix(desktop): replace conflicting local runtime host Generated-by: OpenAI Codex --- .../runtime-host-desktop-manager.test.ts | 43 ++++- .../runtime-host-local-operator.test.ts | 1 - .../runtime-host-local-remote-access.test.ts | 122 ++++++++++++- .../runtime-host-ssh-terminal.test.ts | 5 +- .../runtime-host-upgrade-dialog.test.ts | 62 ++++++- apps/desktop/src/main/runtime-host-boot.ts | 2 + .../src/main/runtime-host-desktop-manager.ts | 35 +++- .../src/main/runtime-host-local-operator.ts | 5 +- .../main/runtime-host-local-remote-access.ts | 172 +++++++++++++++++- .../src/main/runtime-host-ssh-terminal.ts | 5 +- .../src/main/runtime-host-upgrade-copy.ts | 48 +++-- .../src/main/runtime-host-upgrade-dialog.ts | 20 +- packages/cli/src/cli-core.ts | 5 + .../src/runtime-host-lifecycle-transaction.ts | 55 ++++-- .../cli/src/runtime-host-update-command.ts | 17 +- 15 files changed, 530 insertions(+), 67 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 cbc687b668..c7fbce5547 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 @@ -1056,7 +1056,7 @@ test('restarts an idle generation-aware Host without prompting', async () => { }, upgradePrompts: { restartable: async () => assert.fail('idle Host must not prompt before restart'), - waitOnly: async () => assert.fail('restartable conflict used wait-only prompt'), + nonRestartable: async () => assert.fail('restartable conflict used non-restartable prompt'), }, }); @@ -1077,7 +1077,7 @@ test('prompts before restarting a generation-aware Host with active work', async prompts += 1; return 'restart'; }, - waitOnly: async () => assert.fail('restartable conflict used wait-only prompt'), + nonRestartable: async () => assert.fail('restartable conflict used non-restartable prompt'), }, }); @@ -1097,7 +1097,7 @@ test('prompts before restarting a generation-aware Host with a residency', async prompts += 1; return 'restart'; }, - waitOnly: async () => assert.fail('restartable conflict used wait-only prompt'), + nonRestartable: async () => assert.fail('restartable conflict used non-restartable prompt'), }, }); @@ -1117,7 +1117,7 @@ test('prompts before restarting a generation-aware Host with connections', async prompts += 1; return 'restart'; }, - waitOnly: async () => assert.fail('restartable conflict used wait-only prompt'), + nonRestartable: async () => assert.fail('restartable conflict used non-restartable prompt'), }, }); @@ -1137,7 +1137,7 @@ test('prompts when a restartable Host has no activity snapshot', async () => { prompts += 1; return 'restart'; }, - waitOnly: async () => assert.fail('restartable conflict used wait-only prompt'), + nonRestartable: async () => assert.fail('restartable conflict used non-restartable prompt'), }, }); @@ -1160,7 +1160,7 @@ test('waits passively for a Host that cannot be taken over', async () => { }, upgradePrompts: { restartable: async () => assert.fail('wait-only conflict used restart prompt'), - waitOnly: async () => 'wait', + nonRestartable: async () => 'wait', }, waitForHostRetirement: async (registration) => { assert.equal(registration.hostEpoch, conflict.registration.hostEpoch); @@ -1175,6 +1175,35 @@ test('waits passively for a Host that cannot be taken over', async () => { await owner.close(); }); +test('replaces a non-restartable Local Host through the supplied authority and retries', async () => { + const conflict = upgradeRequired(false); + const replacement = candidateHarness(); + let starts = 0; + let replaced: typeof conflict.registration | undefined; + const owner = await startRuntimeHostDesktopManager( + {} as DesktopRuntimeHostCandidateStartInput, + { + startCandidate: async () => { + starts += 1; + return starts === 1 ? conflict : ready(replacement.candidate); + }, + upgradePrompts: { + restartable: async () => assert.fail('non-restartable conflict used restart prompt'), + nonRestartable: async (_conflict, canReplace) => { + assert.equal(canReplace, true); + return 'replace'; + }, + }, + replaceLocalHost: async (registration) => { + replaced = registration; + }, + }, + ); + assert.equal(starts, 2); + assert.equal(replaced?.hostEpoch, conflict.registration.hostEpoch); + await owner.close(); +}); + test('lets the user cancel startup when an incompatible Host owns the root', async () => { const conflict = incompatibleHost('blocked_by_residency'); let presented: DesktopRuntimeHostCandidateStartResult | undefined; @@ -1183,7 +1212,7 @@ test('lets the user cancel startup when an incompatible Host owns the root', asy startCandidate: async () => conflict, upgradePrompts: { restartable: async () => assert.fail('incompatible Host used restart prompt'), - waitOnly: async (actual) => { + nonRestartable: async (actual) => { presented = actual; return 'cancel'; }, diff --git a/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts b/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts index b4155e9947..b8ef7de8b8 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts @@ -192,7 +192,6 @@ test('local update runs the selected package against the exact managed deploymen 'exec', '--yes', '--package', 'maka-agent@0.3.0', '--', 'maka', 'runtime-host', 'service', 'update', '--framed', '--managed-root-id', 'a'.repeat(64), - '--operator-deployment-id', deploymentId, '--expected-service-id', 'a'.repeat(64), '--expected-root-path', '/tmp/maka/root', '--expected-root-id', 'a'.repeat(64), 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 40583de49e..b6c3fbc3a2 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 @@ -23,10 +23,19 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; import { decodeRuntimeHostOwnerConnectionCode } from '@maka/runtime-host/client'; +import { + INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + RUNTIME_HOST_COMPATIBILITY_EPOCH, + RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION, + type HostRegistration, +} from '@maka/runtime-host/protocol'; import type { RuntimeHostDesktopManager } from '../runtime-host-desktop-manager.js'; const RECOVERY_DEPLOYMENT_ID = '33333333-3333-4333-8333-333333333333'; -import { createDesktopLocalRuntimeHostRemoteAccess } from '../runtime-host-local-remote-access.js'; +import { + createDesktopLocalRuntimeHostRemoteAccess, + stopConflictingEphemeralRuntimeHost, +} from '../runtime-host-local-remote-access.js'; import type { createDesktopRuntimeHostLocalOperator } from '../runtime-host-local-operator.js'; test('enabling remote access hands the same root to one managed service before Desktop resumes', async (t) => { @@ -228,6 +237,96 @@ test('keeps the managed service visible when Direct peer support is unavailable' assert.equal(snapshot.managedService, true); }); +test('replaces a conflicting supervised Host through its exact service authority', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-local-managed-conflict-')); + 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 writeManagedLifecycle(clientDataRoot, rootPath, rootId); + let updated = false; + const service = createDesktopLocalRuntimeHostRemoteAccess({ + ipcMain: { handle() {}, removeHandler() {} }, + clientDataRoot, + rootPath, + rootId, + directPeerAvailable: true, + manager: () => undefined, + resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), + operator: { + async runUpdate(input: { + readonly target: { readonly rootId: string; readonly deploymentId?: string }; + readonly allowInterruptActiveTasks?: boolean; + }) { + assert.equal(input.target.rootId, rootId); + assert.equal(input.target.deploymentId, RECOVERY_DEPLOYMENT_ID); + assert.equal(input.allowInterruptActiveTasks, true); + updated = true; + return { + kind: 'result' as const, + action: 'update' as const, + update: { kind: 'updated', previousVersion: '0.2.0', targetVersion: '0.2.0' }, + } as never; + }, + async close() {}, + } as unknown as ReturnType, + }); + t.after(() => service.close()); + + await service.replaceConflictingHost( + hostRegistration({ rootId, lifecycleMode: 'service' }), + new AbortController().signal, + ); + assert.equal(updated, true); +}); + +test('stops only the revalidated ephemeral Host identity', async () => { + const registration = hostRegistration({ lifecycleMode: 'ephemeral' }); + let alive = true; + const signaled: number[] = []; + await stopConflictingEphemeralRuntimeHost( + { + rootPath: '/workspace', + registration, + signal: new AbortController().signal, + }, + { + connectExisting: async () => ({ + kind: 'upgrade_required', + registration, + restartable: false, + }) as never, + signalProcess(pid) { + signaled.push(pid); + alive = false; + }, + isProcessAlive: () => alive, + }, + ); + assert.deepEqual(signaled, [registration.pid]); + + await assert.rejects( + stopConflictingEphemeralRuntimeHost( + { + rootPath: '/workspace', + registration, + signal: new AbortController().signal, + }, + { + connectExisting: async () => ({ + kind: 'upgrade_required', + registration: { ...registration, hostEpoch: 'replacement' }, + restartable: false, + }) as never, + signalProcess: () => assert.fail('a changed Host identity must not be signaled'), + isProcessAlive: () => true, + }, + ), + /could not verify that the process still owns this workspace/u, + ); +}); + 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 })); @@ -738,6 +837,27 @@ async function writeManagedLifecycle( ); } +function hostRegistration( + overrides: Partial> = {}, +): HostRegistration { + return { + kind: 'maka-runtime-host', + schemaVersion: RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION, + rootId: 'a'.repeat(64), + hostEpoch: 'older-host', + endpoint: '/tmp/runtime-host.sock', + protocolMin: 0, + protocolMax: 0, + compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1, + compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + compositionRevision: 'legacy', + state: 'ready', + pid: 42, + createdAt: '2026-08-29T00:00:00.000Z', + ...overrides, + }; +} + function sharedCredential(credentialId: string, status: 'active' | 'pending') { return { credentialId, diff --git a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts index a7b183a614..30696f5461 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts @@ -462,10 +462,7 @@ test('runs an exact update package and reports progress before an active-work re assert.match(remoteCommand, /--package.*maka-agent@1\.3\.0/u); assert.match(remoteCommand, /runtime-host.*service.*update/u); assert.match(remoteCommand, /--managed-root-id.*a{64}/u); - assert.match( - remoteCommand, - /--operator-deployment-id.*00000000-0000-4000-8000-000000000001/u, - ); + assert.doesNotMatch(remoteCommand, /--operator-deployment-id/u); assert.match(remoteCommand, /MAKA_RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST/u); harness.pty.emitData('Password: '); harness.pty.emitData( diff --git a/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts b/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts index 3f515e2c9b..d7e6bb8037 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts @@ -20,6 +20,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { buildRuntimeHostUpgradeDialogOptions } from '../runtime-host-upgrade-copy.js'; +import { createRuntimeHostUpgradePrompts } from '../runtime-host-upgrade-dialog.js'; const conflict = { kind: 'upgrade_required', @@ -39,8 +40,8 @@ const conflict = { } as never; test('localizes upgrade activity without changing decision indexes', () => { - const en = buildRuntimeHostUpgradeDialogOptions(conflict, true, 'en'); - const zh = buildRuntimeHostUpgradeDialogOptions(conflict, true, 'zh'); + const en = buildRuntimeHostUpgradeDialogOptions(conflict, 'restart', 'en'); + const zh = buildRuntimeHostUpgradeDialogOptions(conflict, 'restart', 'zh'); assert.deepEqual(en.buttons, ['Restart Runtime Host', 'Wait', 'Cancel Startup']); assert.deepEqual(zh.buttons, ['重启 Runtime Host', '等待', '取消启动']); assert.equal(en.defaultId, 1); @@ -49,4 +50,61 @@ test('localizes upgrade activity without changing decision indexes', () => { assert.match(zh.detail ?? '', /每日回顾: 1/); assert.match(en.detail ?? '', /Scheduled Task: 2/); assert.match(zh.detail ?? '', /计划任务: 2/); + assert.match(en.detail ?? '', /Process ID \(PID\):/); +}); + +test('offers a non-default replacement action for a non-restartable Local Host', () => { + const options = buildRuntimeHostUpgradeDialogOptions( + { + kind: 'upgrade_required', + restartable: false, + registration: { pid: 42 }, + } as never, + 'replace', + 'en', + ); + assert.deepEqual(options.buttons, ['Stop Host and Continue', 'Wait', 'Cancel Startup']); + assert.equal(options.defaultId, 1); + assert.equal(options.cancelId, 2); + assert.match(options.detail ?? '', /Maka will stop this Host/); +}); + +test('maps the native replacement button to the replace decision', async () => { + const prompts = createRuntimeHostUpgradePrompts( + async () => 'en', + async (options) => { + assert.deepEqual(options.buttons, ['Stop Host and Continue', 'Wait', 'Cancel Startup']); + return { response: 0, checkboxChecked: false }; + }, + ); + assert.equal( + await prompts.nonRestartable( + { + kind: 'upgrade_required', + restartable: false, + registration: { pid: 42 }, + } as never, + true, + ), + 'replace', + ); +}); + +test('does not offer passive waiting for a supervised Host', async () => { + const conflict = { + kind: 'upgrade_required', + restartable: false, + registration: { pid: 42, lifecycleMode: 'service' }, + } as never; + const prompts = createRuntimeHostUpgradePrompts( + async () => 'en', + async (options) => { + assert.deepEqual(options.buttons, ['Stop Host and Continue', 'Cancel Startup']); + assert.equal(options.defaultId, 1); + assert.equal(options.cancelId, 1); + assert.doesNotMatch(options.detail ?? '', /If you wait/u); + return { response: 1, checkboxChecked: false }; + }, + ); + assert.equal(await prompts.nonRestartable(conflict, true), 'cancel'); }); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 4dd1639eab..9c9cf0a7b3 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1013,6 +1013,8 @@ runtimeHostManager = await startRuntimeHostDesktopManager( }); }, recoverLocalHost: (signal) => localRuntimeHostRemoteAccess.recoverBeforeLocalHostStart(signal), + replaceLocalHost: (registration, signal) => + localRuntimeHostRemoteAccess.replaceConflictingHost(registration, 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 474a0d872c..7c38d06c06 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -136,7 +136,7 @@ export class DesktopLocalHostRetirementError extends Error { } export type RuntimeHostRestartDecision = 'restart' | 'wait' | 'cancel'; -export type RuntimeHostWaitDecision = 'wait' | 'cancel'; +export type RuntimeHostNonRestartableDecision = 'replace' | 'wait' | 'cancel'; export class RuntimeHostUpgradeCancelledError extends RuntimeHostPermanentReconnectError { constructor() { @@ -170,7 +170,10 @@ export interface RuntimeHostUpgradePrompts { restartable( conflict: RuntimeHostRestartableConflict, ): Promise; - waitOnly(conflict: RuntimeHostWaitConflict): Promise; + nonRestartable( + conflict: RuntimeHostWaitConflict, + canReplace: boolean, + ): Promise; } interface DesktopRuntimeHostTargetGeneration { @@ -210,6 +213,10 @@ export async function startRuntimeHostDesktopManager( registration: HostRegistration, signal: AbortSignal, ) => Promise; + replaceLocalHost?: ( + registration: HostRegistration, + signal: AbortSignal, + ) => Promise; recoverLocalHost?: (signal: AbortSignal) => Promise; reconnectBackoff?: RuntimeHostReconnectBackoff; pairingFinalizationTimeoutMs?: number; @@ -226,6 +233,7 @@ export async function startRuntimeHostDesktopManager( options.upgradePrompts, options.waitForHostExit ?? waitForProcessExit, options.waitForHostRetirement ?? waitForProcessRetirement, + options.replaceLocalHost, options.recoverLocalHost, options.reconnectBackoff, options.pairingFinalizationTimeoutMs ?? DEFAULT_PAIRING_FINALIZATION_TIMEOUT_MS, @@ -266,6 +274,9 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { registration: HostRegistration, signal: AbortSignal, ) => Promise, + private readonly replaceLocalHost: + | ((registration: HostRegistration, signal: AbortSignal) => Promise) + | undefined, private readonly recoverLocalHost: | ((signal: AbortSignal) => Promise) | undefined, @@ -853,8 +864,19 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { result.kind === 'incompatible' || (result.kind === 'upgrade_required' && !result.restartable) ) { - const decision = await this.#resolveWaitOnly(result); + const canReplace = !target.input.profileTarget && this.replaceLocalHost !== undefined; + const decision = await this.#resolveNonRestartable(result, canReplace); if (decision === 'cancel') throw new RuntimeHostUpgradeCancelledError(); + if (decision === 'replace') { + if (!canReplace || !this.replaceLocalHost) { + throw new RuntimeHostPermanentReconnectError( + 'This Runtime Host cannot be replaced from the current target', + ); + } + await this.replaceLocalHost(result.registration, signal); + takeoverHostEpoch = undefined; + continue; + } takeoverHostEpoch = undefined; await this.waitForHostRetirement(result.registration, signal); continue; @@ -871,8 +893,11 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { return this.#missingUpgradePrompt(); } - #resolveWaitOnly(conflict: RuntimeHostWaitConflict): Promise { - if (this.upgradePrompts) return this.upgradePrompts.waitOnly(conflict); + #resolveNonRestartable( + conflict: RuntimeHostWaitConflict, + canReplace: boolean, + ): Promise { + if (this.upgradePrompts) return this.upgradePrompts.nonRestartable(conflict, canReplace); return this.#missingUpgradePrompt(); } diff --git a/apps/desktop/src/main/runtime-host-local-operator.ts b/apps/desktop/src/main/runtime-host-local-operator.ts index e227c2dfe5..e8c6e1f582 100644 --- a/apps/desktop/src/main/runtime-host-local-operator.ts +++ b/apps/desktop/src/main/runtime-host-local-operator.ts @@ -346,8 +346,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { }, runUpdate(command, onProgress) { if (closed) throw new Error('Local Runtime Host operator is closed'); - const deploymentId = command.target.deploymentId; - if (!deploymentId) { + if (!command.target.deploymentId) { return Promise.reject(new Error('Runtime Host update requires a deployment generation')); } const setupPackage = resolveLocalSetupPackage(command.setupPackage); @@ -367,8 +366,6 @@ export function createDesktopRuntimeHostLocalOperator(input: { '--framed', '--managed-root-id', command.target.rootId, - '--operator-deployment-id', - deploymentId, ...managedTargetArgs(command.target), ...(command.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []), ], 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 275aacde91..d763e5b001 100644 --- a/apps/desktop/src/main/runtime-host-local-remote-access.ts +++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts @@ -23,11 +23,17 @@ import { hostname } from 'node:os'; import { dirname, isAbsolute, join } from 'node:path'; import type { IpcMain } from 'electron'; import { + connectExistingRuntimeHost, 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 { + INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + REMOTE_OWNER_OPERATION_GRANTS, + RUNTIME_HOST_PROTOCOL_VERSION, + type HostRegistration, +} from '@maka/runtime-host/protocol'; import type { DesktopLocalRuntimeHostRemoteAccessEnableResult, DesktopLocalRuntimeHostRemoteAccessSnapshot, @@ -100,6 +106,7 @@ export interface DesktopLocalRuntimeHostRemoteAccess { changeManaged( operation: (target: DesktopRuntimeHostLocalManagementTarget) => Promise, ): Promise; + replaceConflictingHost(registration: HostRegistration, signal: AbortSignal): Promise; recoverBeforeLocalHostStart(signal?: AbortSignal): Promise; recover(): Promise; close(): Promise; @@ -152,6 +159,10 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { readonly resolveManagedDeploymentAuthority?: ( rootId: string, ) => Promise; + readonly replaceEphemeralHost?: ( + registration: HostRegistration, + signal: AbortSignal, + ) => Promise; }): DesktopLocalRuntimeHostRemoteAccess { const lifecyclePath = join(input.clientDataRoot, LIFECYCLE_FILE); const closing = new AbortController(); @@ -663,6 +674,50 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { const managed = requireManagementTarget(lifecycle); return requireManager(input.manager).runManagedLocalHostChange(() => operation(managed)); }), + replaceConflictingHost: (registration, signal) => + serialize(async () => { + signal.throwIfAborted(); + if (registration.rootId !== input.rootId) { + throw conflictReplacementError(registration.pid, 'the workspace identity changed'); + } + if (registration.lifecycleMode !== 'service') { + await (input.replaceEphemeralHost ?? ((expected, operationSignal) => + stopConflictingEphemeralRuntimeHost({ + rootPath: input.rootPath, + registration: expected, + signal: operationSignal, + })))(registration, signal); + return; + } + const managed = requireManagementTarget( + await readLifecycle(lifecyclePath, input.rootPath, input.rootId), + ); + const setupPackage = await input.resolveSetupPackage(signal); + const frame = await input.operator.runUpdate( + { + setupPackage, + target: managed, + allowInterruptActiveTasks: true, + signal, + }, + () => undefined, + ); + if (frame.kind === 'error') { + throw conflictReplacementError(registration.pid, frame.error.message); + } + if (frame.kind === 'progress' || frame.action !== 'update') { + throw conflictReplacementError( + registration.pid, + 'the managed service returned an unrelated result', + ); + } + if (frame.update.kind === 'active_tasks') { + throw conflictReplacementError( + registration.pid, + 'the managed service refused to interrupt active work', + ); + } + }), recoverBeforeLocalHostStart: async (signal) => { const operationSignal = signal ? AbortSignal.any([signal, closing.signal]) : closing.signal; operationSignal.throwIfAborted(); @@ -735,6 +790,121 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { }; } +export async function stopConflictingEphemeralRuntimeHost( + input: { + readonly rootPath: string; + readonly registration: HostRegistration; + readonly signal: AbortSignal; + }, + dependencies: { + readonly connectExisting?: typeof connectExistingRuntimeHost; + readonly signalProcess?: (pid: number) => void; + readonly isProcessAlive?: (pid: number) => boolean; + readonly wait?: (ms: number, signal: AbortSignal) => Promise; + } = {}, +): Promise { + if (input.registration.lifecycleMode === 'service') { + throw conflictReplacementError( + input.registration.pid, + 'a system-supervised Host must be replaced through its service operator', + ); + } + input.signal.throwIfAborted(); + const current = await (dependencies.connectExisting ?? connectExistingRuntimeHost)({ + rootPath: input.rootPath, + protocol: { + min: RUNTIME_HOST_PROTOCOL_VERSION, + max: RUNTIME_HOST_PROTOCOL_VERSION, + }, + compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + }); + if (current.kind === 'connected') { + await current.connection.close(); + return; + } + const currentRegistration = 'registration' in current ? current.registration : undefined; + const isAlive = dependencies.isProcessAlive ?? processIsAlive; + if (!sameHostRegistration(currentRegistration, input.registration)) { + if (!isAlive(input.registration.pid)) return; + throw conflictReplacementError( + input.registration.pid, + 'Maka could not verify that the process still owns this workspace', + ); + } + if (current.kind === 'unavailable') { + throw conflictReplacementError( + input.registration.pid, + 'Maka could not verify the Runtime Host endpoint', + ); + } + input.signal.throwIfAborted(); + try { + (dependencies.signalProcess ?? signalRuntimeHostProcess)(input.registration.pid); + } catch (error) { + if (!isMissingProcessError(error)) throw error; + return; + } + const wait = dependencies.wait ?? waitForAbortableDelay; + const deadline = Date.now() + 10_000; + while (isAlive(input.registration.pid)) { + if (Date.now() >= deadline) { + throw conflictReplacementError( + input.registration.pid, + 'the process did not exit after Maka requested a graceful stop', + ); + } + await wait(100, input.signal); + } +} + +function sameHostRegistration( + current: HostRegistration | undefined, + expected: HostRegistration, +): boolean { + return current?.rootId === expected.rootId && + current.hostEpoch === expected.hostEpoch && + current.pid === expected.pid && + current.endpoint === expected.endpoint; +} + +function signalRuntimeHostProcess(pid: number): void { + process.kill(pid, 'SIGTERM'); +} + +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return !isMissingProcessError(error); + } +} + +function isMissingProcessError(error: unknown): boolean { + return error instanceof Error && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ESRCH'; +} + +function waitForAbortableDelay(ms: number, signal: AbortSignal): Promise { + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(signal.reason); + }; + signal.addEventListener('abort', onAbort, { once: true }); + }); +} + +function conflictReplacementError(pid: number, reason: string): Error { + return new Error(`Maka could not replace Runtime Host process ${pid}: ${reason}`); +} + function supported(directPeerAvailable: boolean): boolean { return directPeerAvailable && (process.platform === 'darwin' || process.platform === 'linux'); } diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts index 8222ce8e9d..8698dcf9c9 100644 --- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts +++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts @@ -1244,8 +1244,7 @@ function runtimeHostUpdateRemoteCommand( setupPackage: PreparedSetupPackage, input: DesktopRuntimeHostSshUpdateInput, ): string { - const deploymentId = input.expectedTarget.deploymentId; - if (!deploymentId) { + if (!input.expectedTarget.deploymentId) { throw new Error('Runtime Host update requires a deployment generation'); } return runtimeHostPackageRemoteCommand( @@ -1257,8 +1256,6 @@ function runtimeHostUpdateRemoteCommand( '--framed', '--managed-root-id', input.expectedTarget.rootId, - '--operator-deployment-id', - deploymentId, ...managedServiceTargetArgs(input.expectedTarget), ...(input.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []), ], diff --git a/apps/desktop/src/main/runtime-host-upgrade-copy.ts b/apps/desktop/src/main/runtime-host-upgrade-copy.ts index 4a4a46c99a..c00195729b 100644 --- a/apps/desktop/src/main/runtime-host-upgrade-copy.ts +++ b/apps/desktop/src/main/runtime-host-upgrade-copy.ts @@ -36,30 +36,42 @@ type ActivityKey = export function buildRuntimeHostUpgradeDialogOptions( conflict: Conflict, - canRestart: boolean, + action: 'restart' | 'replace' | undefined, locale: UiLocale, ): MessageBoxOptions { const activity = conflict.handshake?.activity; const hasWork = (activity?.activeOperations ?? 0) > 0 || (activity?.residencies.length ?? 0) > 0; const copy = UPGRADE_COPY[locale]; - const buttons = canRestart ? [copy.restart, copy.wait, copy.cancel] : [copy.wait, copy.cancel]; + const canWait = conflict.registration.lifecycleMode !== 'service'; + const buttons = action + ? canWait + ? [action === 'restart' ? copy.restart : copy.replace, copy.wait, copy.cancel] + : [action === 'restart' ? copy.restart : copy.replace, copy.cancel] + : canWait + ? [copy.wait, copy.cancel] + : [copy.cancel]; return { type: 'warning', title: copy.title, message: copy.message, - detail: formatActivity(conflict, locale), + detail: formatActivity(conflict, action, locale), buttons, - defaultId: hasWork || !canRestart ? (canRestart ? 1 : 0) : 0, - cancelId: canRestart ? 2 : 1, + defaultId: action === 'restart' && !hasWork ? 0 : action ? 1 : 0, + cancelId: action ? (canWait ? 2 : 1) : canWait ? 1 : 0, noLink: true, }; } -function formatActivity(conflict: Conflict, locale: UiLocale): string { +function formatActivity( + conflict: Conflict, + action: 'restart' | 'replace' | undefined, + locale: UiLocale, +): string { const activity = conflict.handshake?.activity; const copy = UPGRADE_COPY[locale]; const lines: string[] = []; + lines.push(copy.processId(conflict.registration.pid)); if (activity) { const minutes = Math.max(1, Math.round(activity.processUptimeSeconds / 60)); lines.push(copy.uptime(minutes)); @@ -69,9 +81,12 @@ function formatActivity(conflict: Conflict, locale: UiLocale): string { lines.push(`${copy.activity[activityKey(residency.label)]}: ${residency.count}`); } } else lines.push(copy.unknownActivity); - lines.push('', copy.restartWarning); - if (conflict.kind !== 'upgrade_required' || !conflict.restartable) lines.push(copy.exitOwner); - lines.push(copy.waitExplanation); + lines.push('', action === 'replace' ? copy.replaceWarning : copy.restartWarning); + if (action === 'replace') lines.push(copy.replaceExplanation); + else if (conflict.kind !== 'upgrade_required' || !conflict.restartable) { + lines.push(copy.exitOwner(conflict.registration.pid)); + } + if (conflict.registration.lifecycleMode !== 'service') lines.push(copy.waitExplanation); return lines.join('\n'); } @@ -93,15 +108,21 @@ const UPGRADE_COPY = { title: 'Older Runtime Host is running', message: 'Another Runtime Host process still owns this workspace.', restart: 'Restart Runtime Host', + replace: 'Stop Host and Continue', wait: 'Wait', cancel: 'Cancel Startup', uptime: (n: number) => `Running for about ${n} ${n === 1 ? 'minute' : 'minutes'}`, connections: (n: number) => `${n} other client(s) are still connected`, operations: (n: number) => `${n} operation(s) are running`, unknownActivity: 'This Host version cannot report its background activity.', + processId: (pid: number) => `Process ID (PID): ${pid}`, restartWarning: 'Restarting preserves durable state, but it can interrupt in-flight external work.', - exitOwner: 'Exit the process that owns this Host to replace it safely.', + replaceWarning: + 'Stopping preserves durable state, but it can interrupt in-flight external work.', + replaceExplanation: 'Maka will stop this Host, replace it safely, and continue startup.', + exitOwner: (pid: number) => + `End process ${pid} with your system process manager to replace this Host safely.`, waitExplanation: 'If you wait, Maka will continue automatically when this Host exits.', activity: { goal: 'Goal', scheduledTask: 'Scheduled Task', dailyReview: 'Daily Review', @@ -113,14 +134,19 @@ const UPGRADE_COPY = { title: '旧版 Runtime Host 正在运行', message: '另一个 Runtime Host 进程仍占用此工作区。', restart: '重启 Runtime Host', + replace: '停止 Host 并继续', wait: '等待', cancel: '取消启动', uptime: (n: number) => `已运行约 ${n} 分钟`, connections: (n: number) => `仍有 ${n} 个其他客户端连接`, operations: (n: number) => `有 ${n} 个操作正在运行`, unknownActivity: '此 Host 版本无法报告后台活动。', + processId: (pid: number) => `进程 ID (PID):${pid}`, restartWarning: '重启会保留持久化状态,但可能中断正在进行的外部工作。', - exitOwner: '请退出当前占用此 Host 的进程,以便安全替换。', + replaceWarning: '停止 Host 会保留持久化状态,但可能中断正在进行的外部工作。', + replaceExplanation: 'Maka 将停止并安全替换此 Host,然后继续启动。', + exitOwner: (pid: number) => + `请使用系统进程管理工具结束进程 ${pid},以便安全替换此 Host。`, waitExplanation: '若选择等待,当前 Host 退出后 Maka 将自动继续。', activity: { goal: '目标', scheduledTask: '计划任务', dailyReview: '每日回顾', execution: '活动执行', diff --git a/apps/desktop/src/main/runtime-host-upgrade-dialog.ts b/apps/desktop/src/main/runtime-host-upgrade-dialog.ts index b051eabc9e..68e08bf3ec 100644 --- a/apps/desktop/src/main/runtime-host-upgrade-dialog.ts +++ b/apps/desktop/src/main/runtime-host-upgrade-dialog.ts @@ -22,7 +22,7 @@ import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'; import type { RuntimeHostRestartDecision, RuntimeHostUpgradePrompts, - RuntimeHostWaitDecision, + RuntimeHostNonRestartableDecision, } from './runtime-host-desktop-manager.js'; import { buildRuntimeHostUpgradeDialogOptions } from './runtime-host-upgrade-copy.js'; @@ -36,21 +36,29 @@ export function createRuntimeHostUpgradePrompts( return { restartable: async (conflict): Promise => { const locale = await resolveLocale(); + const canWait = conflict.registration.lifecycleMode !== 'service'; const { response } = await showDialog( - buildRuntimeHostUpgradeDialogOptions(conflict, true, locale), + buildRuntimeHostUpgradeDialogOptions(conflict, 'restart', locale), locale, ); if (response === 0) return 'restart'; - if (response === 1) return 'wait'; + if (canWait && response === 1) return 'wait'; return 'cancel'; }, - waitOnly: async (conflict): Promise => { + nonRestartable: async ( + conflict, + canReplace, + ): Promise => { const locale = await resolveLocale(); + const canWait = conflict.registration.lifecycleMode !== 'service'; const { response } = await showDialog( - buildRuntimeHostUpgradeDialogOptions(conflict, false, locale), + buildRuntimeHostUpgradeDialogOptions(conflict, canReplace ? 'replace' : undefined, locale), locale, ); - return response === 0 ? 'wait' : 'cancel'; + if (!canReplace) return canWait && response === 0 ? 'wait' : 'cancel'; + if (response === 0) return 'replace'; + if (canWait && response === 1) return 'wait'; + return 'cancel'; }, }; } diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index 0dd8e4c268..085ecc51d6 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -510,6 +510,10 @@ export async function runMakaCli( case 'runtime-host-service-update': { const { runManagedRuntimeHostSelectedUpdateCli, runManagedRuntimeHostUpdateCli } = await import('./runtime-host-update-command.js'); + const { RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV } = await import( + '@maka/runtime-host/operator' + ); + const sourcePackageIntegrity = process.env[RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV]; const serviceDataRoots = command.clientDataRoot ? deriveMakaDataRoots(command.clientDataRoot) : dataRoots; @@ -534,6 +538,7 @@ export async function runMakaCli( clientDataRoot: serviceDataRoots.clientDataRoot, defaultRootPath: serviceDataRoots.workspaceRoot, sourcePackageRoot: fileURLToPath(new URL('..', import.meta.url)), + ...(sourcePackageIntegrity ? { sourcePackageIntegrity } : {}), version, expectedTarget: command.expectedTarget, ...(command.managedRootId ? { managedRootId: command.managedRootId } : {}), diff --git a/packages/cli/src/runtime-host-lifecycle-transaction.ts b/packages/cli/src/runtime-host-lifecycle-transaction.ts index 69802ed8f1..aa9d48e42f 100644 --- a/packages/cli/src/runtime-host-lifecycle-transaction.ts +++ b/packages/cli/src/runtime-host-lifecycle-transaction.ts @@ -338,6 +338,13 @@ export async function retireRuntimeHostLifecycleOwner(input: { }, }); if (connected.kind !== 'connected') { + if (input.allowInterruptActiveTasks && input.supervisor) { + const status = await input.supervisor.status(); + if (status.active && status.pid !== null) { + await input.supervisor.retire(); + return waitForRuntimeHostLifecycleOwner(capability, input.timeoutMs ?? 45_000); + } + } throw new RuntimeHostLifecycleTransactionError( 'transition_failed', `Runtime Host cannot prepare for retirement: ${connected.kind}`, @@ -370,7 +377,14 @@ export async function retireRuntimeHostLifecycleOwner(input: { } finally { await connected.connection.close().catch(() => undefined); } - const deadline = Date.now() + (input.timeoutMs ?? 45_000); + return waitForRuntimeHostLifecycleOwner(capability, input.timeoutMs ?? 45_000); +} + +async function waitForRuntimeHostLifecycleOwner( + capability: Awaited>, + timeoutMs: number, +): Promise> { + const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const owner = await tryAcquireStateRootOwner(capability); if (owner) return { kind: 'retired', owner }; @@ -646,23 +660,9 @@ export async function verifyRuntimeHostLifecycleReady( timeoutMs = 45_000, ): Promise { const canonical = decodeRuntimeHostManagedDeploymentConfig(config); - await deps.verifyOperator(canonical); + await verifyRuntimeHostLifecycleProjection(canonical, deps); if (canonical.lifecycle.mode !== 'supervised') return; const provider = deps.resolveProvider(canonical.lifecycle.provider); - const supervisorDefinition = runtimeHostSupervisorDefinition(canonical); - await provider.supervisor.verify(supervisorDefinition); - if (canonical.reconciliation.trigger === 'scheduled') { - await provider.reconciliationTrigger.verify( - runtimeHostReconciliationTriggerDefinition(canonical), - ); - const trigger = await provider.reconciliationTrigger.status(); - if (!trigger.installed || !trigger.active) { - throw new RuntimeHostLifecycleTransactionError( - 'transition_failed', - 'Runtime Host reconciliation scheduling is not active', - ); - } - } const deadline = Date.now() + timeoutMs; let lastFailure: unknown = new Error('Runtime Host is not ready'); while (Date.now() < deadline) { @@ -703,6 +703,29 @@ export async function verifyRuntimeHostLifecycleReady( ); } +/** Verifies the durable operator and supervisor projection without requiring a compatible Host. */ +export async function verifyRuntimeHostLifecycleProjection( + config: RuntimeHostManagedDeploymentConfig, + deps: RuntimeHostLifecycleTransactionDeps, +): Promise { + const canonical = decodeRuntimeHostManagedDeploymentConfig(config); + await deps.verifyOperator(canonical); + if (canonical.lifecycle.mode !== 'supervised') return; + const provider = deps.resolveProvider(canonical.lifecycle.provider); + await provider.supervisor.verify(runtimeHostSupervisorDefinition(canonical)); + if (canonical.reconciliation.trigger !== 'scheduled') return; + await provider.reconciliationTrigger.verify( + runtimeHostReconciliationTriggerDefinition(canonical), + ); + const trigger = await provider.reconciliationTrigger.status(); + if (!trigger.installed || !trigger.active) { + throw new RuntimeHostLifecycleTransactionError( + 'transition_failed', + 'Runtime Host reconciliation scheduling is not active', + ); + } +} + export function runtimeHostSupervisorDefinition( config: RuntimeHostManagedDeploymentConfig, ): RuntimeHostProviderDefinition { diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index f720426afd..a920aa0709 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -79,6 +79,7 @@ import { canDiscardRuntimeHostLifecycleDesiredArtifacts, replaceRuntimeHostLifecycle, resolveRecoverableRuntimeHostManagedDeployment, + verifyRuntimeHostLifecycleProjection, type RuntimeHostLifecycleTransactionDeps, } from './runtime-host-lifecycle-transaction.js'; import { manageRuntimeHostManagedLifecycle } from './runtime-host-managed-lifecycle-manager.js'; @@ -92,6 +93,7 @@ export interface RuntimeHostUpdateCliOptions { readonly clientDataRoot: string; readonly defaultRootPath: string; readonly sourcePackageRoot: string; + readonly sourcePackageIntegrity?: string; readonly version: string; readonly expectedTarget: RuntimeHostManagedServiceTarget; readonly managedRootId?: string; @@ -248,7 +250,7 @@ export async function runManagedRuntimeHostUpdateCli( const targetCliPath = resolveRuntimeHostManagedPackageCliPath( deploymentRoot, options.version, - options.registrySelection?.integrity, + options.sourcePackageIntegrity ?? options.registrySelection?.integrity, ); const expectedCurrent = options.registrySelection?.current; const selectedDeploymentStillCurrent = @@ -328,8 +330,11 @@ export async function runManagedRuntimeHostUpdateCli( clientDataRoot: options.clientDataRoot, sourcePackageRoot: options.sourcePackageRoot, version: options.version, - ...(options.registrySelection - ? { packageIntegrity: options.registrySelection.integrity } + ...((options.sourcePackageIntegrity ?? options.registrySelection?.integrity) + ? { + packageIntegrity: + options.sourcePackageIntegrity ?? options.registrySelection?.integrity, + } : {}), }), ); @@ -579,7 +584,7 @@ async function runCanonicalRuntimeHostUpdate( const recovered = await resolveRecoverableRuntimeHostManagedDeployment( options.managedRootId, lifecycleDeps, - { expectedTarget: options.expectedTarget, ensureAvailable: true }, + { expectedTarget: options.expectedTarget }, ); if (recovered.kind === 'absent') { throw new RuntimeHostServiceManagerError( @@ -588,6 +593,7 @@ async function runCanonicalRuntimeHostUpdate( ); } const current = recovered.config; + await verifyRuntimeHostLifecycleProjection(current, lifecycleDeps); assertRuntimeHostManagedOperatorConfig( current, options.operatorDeploymentId, @@ -606,6 +612,7 @@ async function runCanonicalRuntimeHostUpdate( { resolveProvider: resolveRuntimeHostLifecycleProvider }, ); const targetIntegrity = + options.sourcePackageIntegrity ?? options.registrySelection?.integrity ?? (options.version === current.launch.package.version ? current.launch.package.integrity @@ -613,7 +620,7 @@ async function runCanonicalRuntimeHostUpdate( if (!targetIntegrity) { throw new RuntimeHostServiceManagerError( 'invalid_launch', - 'An exact registry package identity is required for a managed update', + 'An exact package identity is required for a managed update', ); } const expectedCurrent = options.registrySelection?.current; From 2ed8f1822e1c0099fc0d61f628677361d46220ef Mon Sep 17 00:00:00 2001 From: Wang Date: Sat, 29 Aug 2026 23:19:06 +0800 Subject: [PATCH 2/6] fix(runtime-host): bind conflict replacement authority Generated-by: OpenAI Codex --- .../runtime-host-desktop-manager.test.ts | 23 +- .../runtime-host-local-operator.test.ts | 3 + .../runtime-host-local-remote-access.test.ts | 73 ++---- .../runtime-host-ssh-terminal.test.ts | 1 + .../runtime-host-upgrade-dialog.test.ts | 42 ++-- apps/desktop/src/main/runtime-host-boot.ts | 4 +- .../src/main/runtime-host-desktop-manager.ts | 42 +++- .../src/main/runtime-host-local-operator.ts | 11 +- .../main/runtime-host-local-remote-access.ts | 231 +++++------------- .../src/main/runtime-host-setup-package.ts | 12 + .../src/main/runtime-host-ssh-terminal.ts | 9 +- .../src/main/runtime-host-upgrade-copy.ts | 74 ++++-- .../src/main/runtime-host-upgrade-dialog.ts | 30 ++- .../runtime-host-selected-update.test.ts | 27 +- .../runtime-host-service-manager.test.ts | 31 +++ packages/cli/src/cli-core.ts | 2 + packages/cli/src/runtime-host-cli.ts | 45 ++++ .../src/runtime-host-lifecycle-transaction.ts | 41 +++- .../cli/src/runtime-host-update-command.ts | 18 +- 19 files changed, 409 insertions(+), 310 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 c7fbce5547..e6a39be1aa 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 @@ -1176,10 +1176,14 @@ test('waits passively for a Host that cannot be taken over', async () => { }); test('replaces a non-restartable Local Host through the supplied authority and retries', async () => { - const conflict = upgradeRequired(false); + const observed = upgradeRequired(false); + const conflict = { + ...observed, + registration: { ...observed.registration, lifecycleMode: 'service' as const }, + }; const replacement = candidateHarness(); let starts = 0; - let replaced: typeof conflict.registration | undefined; + let replaced: typeof observed.registration | undefined; const owner = await startRuntimeHostDesktopManager( {} as DesktopRuntimeHostCandidateStartInput, { @@ -1189,14 +1193,16 @@ test('replaces a non-restartable Local Host through the supplied authority and r }, upgradePrompts: { restartable: async () => assert.fail('non-restartable conflict used restart prompt'), - nonRestartable: async (_conflict, canReplace) => { - assert.equal(canReplace, true); + nonRestartable: async (_conflict, actions) => { + assert.deepEqual(actions, { canReplace: true, canWait: false }); return 'replace'; }, }, - replaceLocalHost: async (registration) => { - replaced = registration; - }, + resolveLocalHostReplacement: async (registration) => ({ + replace: async () => { + replaced = registration; + }, + }), }, ); assert.equal(starts, 2); @@ -1212,8 +1218,9 @@ test('lets the user cancel startup when an incompatible Host owns the root', asy startCandidate: async () => conflict, upgradePrompts: { restartable: async () => assert.fail('incompatible Host used restart prompt'), - nonRestartable: async (actual) => { + nonRestartable: async (actual, actions) => { presented = actual; + assert.deepEqual(actions, { canReplace: false, canWait: true }); return 'cancel'; }, }, diff --git a/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts b/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts index b8ef7de8b8..8b9e7082d5 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts @@ -183,6 +183,7 @@ test('local update runs the selected package against the exact managed deploymen rootId: 'a'.repeat(64), deploymentId, }, + expectedHost: { hostEpoch: 'older-host', pid: 42 }, }, (phase) => phases.push(phase), ); @@ -191,7 +192,9 @@ test('local update runs the selected package against the exact managed deploymen assert.deepEqual(args, [ 'exec', '--yes', '--package', 'maka-agent@0.3.0', '--', 'maka', 'runtime-host', 'service', 'update', '--framed', + '--target', '0.3.0', '--managed-root-id', 'a'.repeat(64), + '--expected-host-json', JSON.stringify({ hostEpoch: 'older-host', pid: 42 }), '--expected-service-id', 'a'.repeat(64), '--expected-root-path', '/tmp/maka/root', '--expected-root-id', 'a'.repeat(64), 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 b6c3fbc3a2..c72e59aa6c 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 @@ -32,10 +32,7 @@ import { import type { RuntimeHostDesktopManager } from '../runtime-host-desktop-manager.js'; const RECOVERY_DEPLOYMENT_ID = '33333333-3333-4333-8333-333333333333'; -import { - createDesktopLocalRuntimeHostRemoteAccess, - stopConflictingEphemeralRuntimeHost, -} from '../runtime-host-local-remote-access.js'; +import { createDesktopLocalRuntimeHostRemoteAccess } from '../runtime-host-local-remote-access.js'; import type { createDesktopRuntimeHostLocalOperator } from '../runtime-host-local-operator.js'; test('enabling remote access hands the same root to one managed service before Desktop resumes', async (t) => { @@ -237,14 +234,13 @@ test('keeps the managed service visible when Direct peer support is unavailable' assert.equal(snapshot.managedService, true); }); -test('replaces a conflicting supervised Host through its exact service authority', async (t) => { +test('replaces a conflicting supervised Host through canonical authority without a receipt', async (t) => { const base = await mkdtemp(join(tmpdir(), 'maka-local-managed-conflict-')); 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 writeManagedLifecycle(clientDataRoot, rootPath, rootId); let updated = false; const service = createDesktopLocalRuntimeHostRemoteAccess({ ipcMain: { handle() {}, removeHandler() {} }, @@ -254,13 +250,27 @@ test('replaces a conflicting supervised Host through its exact service authority directPeerAvailable: true, manager: () => undefined, resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), + resolveManagedDeploymentAuthority: async () => ({ + kind: 'active', + lifecycleMode: 'supervised', + target: { + schemaVersion: 1, + serviceId: rootId, + rootPath, + rootId, + operatorPath: join(base, 'operator'), + deploymentId: RECOVERY_DEPLOYMENT_ID, + }, + }), operator: { async runUpdate(input: { readonly target: { readonly rootId: string; readonly deploymentId?: string }; + readonly expectedHost?: { readonly hostEpoch: string; readonly pid: number }; readonly allowInterruptActiveTasks?: boolean; }) { assert.equal(input.target.rootId, rootId); assert.equal(input.target.deploymentId, RECOVERY_DEPLOYMENT_ID); + assert.deepEqual(input.expectedHost, { hostEpoch: 'older-host', pid: 42 }); assert.equal(input.allowInterruptActiveTasks, true); updated = true; return { @@ -274,59 +284,15 @@ test('replaces a conflicting supervised Host through its exact service authority }); t.after(() => service.close()); - await service.replaceConflictingHost( + const replacement = await service.resolveConflictingHostReplacement( hostRegistration({ rootId, lifecycleMode: 'service' }), new AbortController().signal, ); + assert.ok(replacement); + await replacement.replace(); assert.equal(updated, true); }); -test('stops only the revalidated ephemeral Host identity', async () => { - const registration = hostRegistration({ lifecycleMode: 'ephemeral' }); - let alive = true; - const signaled: number[] = []; - await stopConflictingEphemeralRuntimeHost( - { - rootPath: '/workspace', - registration, - signal: new AbortController().signal, - }, - { - connectExisting: async () => ({ - kind: 'upgrade_required', - registration, - restartable: false, - }) as never, - signalProcess(pid) { - signaled.push(pid); - alive = false; - }, - isProcessAlive: () => alive, - }, - ); - assert.deepEqual(signaled, [registration.pid]); - - await assert.rejects( - stopConflictingEphemeralRuntimeHost( - { - rootPath: '/workspace', - registration, - signal: new AbortController().signal, - }, - { - connectExisting: async () => ({ - kind: 'upgrade_required', - registration: { ...registration, hostEpoch: 'replacement' }, - restartable: false, - }) as never, - signalProcess: () => assert.fail('a changed Host identity must not be signaled'), - isProcessAlive: () => true, - }, - ), - /could not verify that the process still owns this workspace/u, - ); -}); - 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 })); @@ -408,6 +374,7 @@ test('adopts committed managed authority for every pending receipt without repla manager: () => assert.fail('pre-start reconciliation must not require the Local manager'), resolveManagedDeploymentAuthority: async () => ({ kind: 'active', + lifecycleMode: 'supervised', target: { schemaVersion: 1, serviceId: rootId, diff --git a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts index 30696f5461..5caf67bd9b 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts @@ -461,6 +461,7 @@ test('runs an exact update package and reports progress before an active-work re const remoteCommand = harness.launchArgs.at(-1)?.at(-1) ?? ''; assert.match(remoteCommand, /--package.*maka-agent@1\.3\.0/u); assert.match(remoteCommand, /runtime-host.*service.*update/u); + assert.match(remoteCommand, /--target.*1\.3\.0/u); assert.match(remoteCommand, /--managed-root-id.*a{64}/u); assert.doesNotMatch(remoteCommand, /--operator-deployment-id/u); assert.match(remoteCommand, /MAKA_RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST/u); diff --git a/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts b/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts index d7e6bb8037..9edb5f5888 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { buildRuntimeHostUpgradeDialogOptions } from '../runtime-host-upgrade-copy.js'; +import { buildRuntimeHostUpgradeDialog } from '../runtime-host-upgrade-copy.js'; import { createRuntimeHostUpgradePrompts } from '../runtime-host-upgrade-dialog.js'; const conflict = { @@ -40,8 +40,16 @@ const conflict = { } as never; test('localizes upgrade activity without changing decision indexes', () => { - const en = buildRuntimeHostUpgradeDialogOptions(conflict, 'restart', 'en'); - const zh = buildRuntimeHostUpgradeDialogOptions(conflict, 'restart', 'zh'); + const en = buildRuntimeHostUpgradeDialog( + conflict, + { action: 'restart', canWait: true }, + 'en', + ).options; + const zh = buildRuntimeHostUpgradeDialog( + conflict, + { action: 'restart', canWait: true }, + 'zh', + ).options; assert.deepEqual(en.buttons, ['Restart Runtime Host', 'Wait', 'Cancel Startup']); assert.deepEqual(zh.buttons, ['重启 Runtime Host', '等待', '取消启动']); assert.equal(en.defaultId, 1); @@ -53,27 +61,14 @@ test('localizes upgrade activity without changing decision indexes', () => { assert.match(en.detail ?? '', /Process ID \(PID\):/); }); -test('offers a non-default replacement action for a non-restartable Local Host', () => { - const options = buildRuntimeHostUpgradeDialogOptions( - { - kind: 'upgrade_required', - restartable: false, - registration: { pid: 42 }, - } as never, - 'replace', - 'en', - ); - assert.deepEqual(options.buttons, ['Stop Host and Continue', 'Wait', 'Cancel Startup']); - assert.equal(options.defaultId, 1); - assert.equal(options.cancelId, 2); - assert.match(options.detail ?? '', /Maka will stop this Host/); -}); - -test('maps the native replacement button to the replace decision', async () => { +test('maps the non-default replacement choice to the replace decision', async () => { const prompts = createRuntimeHostUpgradePrompts( async () => 'en', async (options) => { assert.deepEqual(options.buttons, ['Stop Host and Continue', 'Wait', 'Cancel Startup']); + assert.equal(options.defaultId, 1); + assert.equal(options.cancelId, 2); + assert.match(options.detail ?? '', /Maka will stop this Host/); return { response: 0, checkboxChecked: false }; }, ); @@ -84,7 +79,7 @@ test('maps the native replacement button to the replace decision', async () => { restartable: false, registration: { pid: 42 }, } as never, - true, + { canReplace: true, canWait: true }, ), 'replace', ); @@ -106,5 +101,8 @@ test('does not offer passive waiting for a supervised Host', async () => { return { response: 1, checkboxChecked: false }; }, ); - assert.equal(await prompts.nonRestartable(conflict, true), 'cancel'); + assert.equal( + await prompts.nonRestartable(conflict, { canReplace: true, canWait: false }), + 'cancel', + ); }); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 9c9cf0a7b3..e002cadeb8 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1013,8 +1013,8 @@ runtimeHostManager = await startRuntimeHostDesktopManager( }); }, recoverLocalHost: (signal) => localRuntimeHostRemoteAccess.recoverBeforeLocalHostStart(signal), - replaceLocalHost: (registration, signal) => - localRuntimeHostRemoteAccess.replaceConflictingHost(registration, signal), + resolveLocalHostReplacement: (registration, signal) => + localRuntimeHostRemoteAccess.resolveConflictingHostReplacement(registration, 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 7c38d06c06..82d4cc97ca 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -138,6 +138,15 @@ export class DesktopLocalHostRetirementError extends Error { export type RuntimeHostRestartDecision = 'restart' | 'wait' | 'cancel'; export type RuntimeHostNonRestartableDecision = 'replace' | 'wait' | 'cancel'; +export interface RuntimeHostNonRestartableActions { + readonly canReplace: boolean; + readonly canWait: boolean; +} + +export interface RuntimeHostLocalReplacement { + replace(): Promise; +} + export class RuntimeHostUpgradeCancelledError extends RuntimeHostPermanentReconnectError { constructor() { super('Runtime Host restart was cancelled'); @@ -172,7 +181,7 @@ export interface RuntimeHostUpgradePrompts { ): Promise; nonRestartable( conflict: RuntimeHostWaitConflict, - canReplace: boolean, + actions: RuntimeHostNonRestartableActions, ): Promise; } @@ -213,10 +222,10 @@ export async function startRuntimeHostDesktopManager( registration: HostRegistration, signal: AbortSignal, ) => Promise; - replaceLocalHost?: ( + resolveLocalHostReplacement?: ( registration: HostRegistration, signal: AbortSignal, - ) => Promise; + ) => Promise; recoverLocalHost?: (signal: AbortSignal) => Promise; reconnectBackoff?: RuntimeHostReconnectBackoff; pairingFinalizationTimeoutMs?: number; @@ -233,7 +242,7 @@ export async function startRuntimeHostDesktopManager( options.upgradePrompts, options.waitForHostExit ?? waitForProcessExit, options.waitForHostRetirement ?? waitForProcessRetirement, - options.replaceLocalHost, + options.resolveLocalHostReplacement, options.recoverLocalHost, options.reconnectBackoff, options.pairingFinalizationTimeoutMs ?? DEFAULT_PAIRING_FINALIZATION_TIMEOUT_MS, @@ -274,8 +283,11 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { registration: HostRegistration, signal: AbortSignal, ) => Promise, - private readonly replaceLocalHost: - | ((registration: HostRegistration, signal: AbortSignal) => Promise) + private readonly resolveLocalHostReplacement: + | (( + registration: HostRegistration, + signal: AbortSignal, + ) => Promise) | undefined, private readonly recoverLocalHost: | ((signal: AbortSignal) => Promise) @@ -864,16 +876,22 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { result.kind === 'incompatible' || (result.kind === 'upgrade_required' && !result.restartable) ) { - const canReplace = !target.input.profileTarget && this.replaceLocalHost !== undefined; - const decision = await this.#resolveNonRestartable(result, canReplace); + const replacement = target.input.profileTarget + ? undefined + : await this.resolveLocalHostReplacement?.(result.registration, signal); + const decision = await this.#resolveNonRestartable(result, { + canReplace: replacement !== undefined, + canWait: + replacement === undefined && result.registration.lifecycleMode !== 'service', + }); if (decision === 'cancel') throw new RuntimeHostUpgradeCancelledError(); if (decision === 'replace') { - if (!canReplace || !this.replaceLocalHost) { + if (!replacement) { throw new RuntimeHostPermanentReconnectError( 'This Runtime Host cannot be replaced from the current target', ); } - await this.replaceLocalHost(result.registration, signal); + await replacement.replace(); takeoverHostEpoch = undefined; continue; } @@ -895,9 +913,9 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { #resolveNonRestartable( conflict: RuntimeHostWaitConflict, - canReplace: boolean, + actions: RuntimeHostNonRestartableActions, ): Promise { - if (this.upgradePrompts) return this.upgradePrompts.nonRestartable(conflict, canReplace); + if (this.upgradePrompts) return this.upgradePrompts.nonRestartable(conflict, actions); return this.#missingUpgradePrompt(); } diff --git a/apps/desktop/src/main/runtime-host-local-operator.ts b/apps/desktop/src/main/runtime-host-local-operator.ts index e8c6e1f582..6657e788fc 100644 --- a/apps/desktop/src/main/runtime-host-local-operator.ts +++ b/apps/desktop/src/main/runtime-host-local-operator.ts @@ -47,7 +47,10 @@ import { type RuntimeHostSetupFrame, } from '@maka/runtime-host/operator'; import { createRuntimeHostFramedOutputFilter } from './runtime-host-framed-output.js'; -import type { DesktopRuntimeHostSetupPackage } from './runtime-host-setup-package.js'; +import { + runtimeHostSetupPackageVersion, + type DesktopRuntimeHostSetupPackage, +} from './runtime-host-setup-package.js'; const SETUP_TIMEOUT_MS = 10 * 60_000; const SETUP_FRAME_PENDING_MAX = 20 * 1024; @@ -179,6 +182,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { input: { readonly setupPackage: DesktopRuntimeHostSetupPackage; readonly target: DesktopRuntimeHostLocalServiceTarget; + readonly expectedHost?: { readonly hostEpoch: string; readonly pid: number }; readonly allowInterruptActiveTasks?: boolean; readonly signal?: AbortSignal; }, @@ -350,6 +354,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { return Promise.reject(new Error('Runtime Host update requires a deployment generation')); } const setupPackage = resolveLocalSetupPackage(command.setupPackage); + const targetVersion = runtimeHostSetupPackageVersion(command.setupPackage); return runServiceFrameProcess({ command: { executable: 'npm', @@ -364,8 +369,12 @@ export function createDesktopRuntimeHostLocalOperator(input: { 'service', 'update', '--framed', + ...(targetVersion ? ['--target', targetVersion] : []), '--managed-root-id', command.target.rootId, + ...(command.expectedHost + ? ['--expected-host-json', JSON.stringify(command.expectedHost)] + : []), ...managedTargetArgs(command.target), ...(command.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []), ], 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 d763e5b001..49bc2b76a7 100644 --- a/apps/desktop/src/main/runtime-host-local-remote-access.ts +++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts @@ -23,17 +23,11 @@ import { hostname } from 'node:os'; import { dirname, isAbsolute, join } from 'node:path'; import type { IpcMain } from 'electron'; import { - connectExistingRuntimeHost, consumeAccessCredentialDelivery, encodeRuntimeHostOwnerConnectionCode, } from '@maka/runtime-host/client'; import { resolveRuntimeHostManagedDeploymentAuthority } from '@maka/runtime-host/operator'; -import { - INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, - REMOTE_OWNER_OPERATION_GRANTS, - RUNTIME_HOST_PROTOCOL_VERSION, - type HostRegistration, -} from '@maka/runtime-host/protocol'; +import { REMOTE_OWNER_OPERATION_GRANTS, type HostRegistration } from '@maka/runtime-host/protocol'; import type { DesktopLocalRuntimeHostRemoteAccessEnableResult, DesktopLocalRuntimeHostRemoteAccessSnapshot, @@ -86,7 +80,11 @@ interface LocalServiceManaged extends LocalServiceTarget { } type LocalManagedDeploymentAuthority = - | { readonly kind: 'active'; readonly target: LocalServiceTarget } + | { + readonly kind: 'active'; + readonly lifecycleMode: 'on_demand' | 'supervised'; + readonly target: LocalServiceTarget; + } | { readonly kind: 'transition' }; export interface DesktopRuntimeHostLocalManagementTarget @@ -106,7 +104,10 @@ export interface DesktopLocalRuntimeHostRemoteAccess { changeManaged( operation: (target: DesktopRuntimeHostLocalManagementTarget) => Promise, ): Promise; - replaceConflictingHost(registration: HostRegistration, signal: AbortSignal): Promise; + resolveConflictingHostReplacement( + registration: HostRegistration, + signal: AbortSignal, + ): Promise<{ replace(): Promise } | undefined>; recoverBeforeLocalHostStart(signal?: AbortSignal): Promise; recover(): Promise; close(): Promise; @@ -159,10 +160,6 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { readonly resolveManagedDeploymentAuthority?: ( rootId: string, ) => Promise; - readonly replaceEphemeralHost?: ( - registration: HostRegistration, - signal: AbortSignal, - ) => Promise; }): DesktopLocalRuntimeHostRemoteAccess { const lifecyclePath = join(input.clientDataRoot, LIFECYCLE_FILE); const closing = new AbortController(); @@ -183,6 +180,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { if (authority.record.state !== 'active') return { kind: 'transition' }; return { kind: 'active', + lifecycleMode: authority.record.lifecycle.mode, target: requireServiceTarget( { schemaVersion: 1, @@ -674,50 +672,58 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { const managed = requireManagementTarget(lifecycle); return requireManager(input.manager).runManagedLocalHostChange(() => operation(managed)); }), - replaceConflictingHost: (registration, signal) => - serialize(async () => { - signal.throwIfAborted(); - if (registration.rootId !== input.rootId) { - throw conflictReplacementError(registration.pid, 'the workspace identity changed'); - } - if (registration.lifecycleMode !== 'service') { - await (input.replaceEphemeralHost ?? ((expected, operationSignal) => - stopConflictingEphemeralRuntimeHost({ - rootPath: input.rootPath, - registration: expected, - signal: operationSignal, - })))(registration, signal); - return; - } - const managed = requireManagementTarget( - await readLifecycle(lifecyclePath, input.rootPath, input.rootId), - ); - const setupPackage = await input.resolveSetupPackage(signal); - const frame = await input.operator.runUpdate( - { - setupPackage, - target: managed, - allowInterruptActiveTasks: true, - signal, - }, - () => undefined, - ); - if (frame.kind === 'error') { - throw conflictReplacementError(registration.pid, frame.error.message); - } - if (frame.kind === 'progress' || frame.action !== 'update') { - throw conflictReplacementError( - registration.pid, - 'the managed service returned an unrelated result', - ); - } - if (frame.update.kind === 'active_tasks') { - throw conflictReplacementError( - registration.pid, - 'the managed service refused to interrupt active work', - ); - } - }), + resolveConflictingHostReplacement: async (registration, signal) => { + signal.throwIfAborted(); + if (registration.rootId !== input.rootId) { + throw conflictReplacementError(registration.pid, 'the workspace identity changed'); + } + if (registration.lifecycleMode === 'ephemeral') return undefined; + const authority = await resolveManagedDeploymentAuthority(registration.rootId); + if ( + !authority || + authority.kind !== 'active' || + authority.lifecycleMode !== 'supervised' + ) { + return undefined; + } + const target = authority.target; + return { + replace: () => + serialize(async () => { + signal.throwIfAborted(); + const setupPackage = await input.resolveSetupPackage(signal); + const frame = await input.operator.runUpdate( + { + setupPackage, + target, + expectedHost: { + hostEpoch: registration.hostEpoch, + pid: registration.pid, + }, + allowInterruptActiveTasks: true, + signal, + }, + () => undefined, + ); + if (frame.kind === 'error') { + if (frame.error.code === 'target_mismatch') return; + throw conflictReplacementError(registration.pid, frame.error.message); + } + if (frame.kind === 'progress' || frame.action !== 'update') { + throw conflictReplacementError( + registration.pid, + 'the managed service returned an unrelated result', + ); + } + if (frame.update.kind === 'active_tasks') { + throw conflictReplacementError( + registration.pid, + 'the managed service refused to interrupt active work', + ); + } + }), + }; + }, recoverBeforeLocalHostStart: async (signal) => { const operationSignal = signal ? AbortSignal.any([signal, closing.signal]) : closing.signal; operationSignal.throwIfAborted(); @@ -790,117 +796,6 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { }; } -export async function stopConflictingEphemeralRuntimeHost( - input: { - readonly rootPath: string; - readonly registration: HostRegistration; - readonly signal: AbortSignal; - }, - dependencies: { - readonly connectExisting?: typeof connectExistingRuntimeHost; - readonly signalProcess?: (pid: number) => void; - readonly isProcessAlive?: (pid: number) => boolean; - readonly wait?: (ms: number, signal: AbortSignal) => Promise; - } = {}, -): Promise { - if (input.registration.lifecycleMode === 'service') { - throw conflictReplacementError( - input.registration.pid, - 'a system-supervised Host must be replaced through its service operator', - ); - } - input.signal.throwIfAborted(); - const current = await (dependencies.connectExisting ?? connectExistingRuntimeHost)({ - rootPath: input.rootPath, - protocol: { - min: RUNTIME_HOST_PROTOCOL_VERSION, - max: RUNTIME_HOST_PROTOCOL_VERSION, - }, - compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, - }); - if (current.kind === 'connected') { - await current.connection.close(); - return; - } - const currentRegistration = 'registration' in current ? current.registration : undefined; - const isAlive = dependencies.isProcessAlive ?? processIsAlive; - if (!sameHostRegistration(currentRegistration, input.registration)) { - if (!isAlive(input.registration.pid)) return; - throw conflictReplacementError( - input.registration.pid, - 'Maka could not verify that the process still owns this workspace', - ); - } - if (current.kind === 'unavailable') { - throw conflictReplacementError( - input.registration.pid, - 'Maka could not verify the Runtime Host endpoint', - ); - } - input.signal.throwIfAborted(); - try { - (dependencies.signalProcess ?? signalRuntimeHostProcess)(input.registration.pid); - } catch (error) { - if (!isMissingProcessError(error)) throw error; - return; - } - const wait = dependencies.wait ?? waitForAbortableDelay; - const deadline = Date.now() + 10_000; - while (isAlive(input.registration.pid)) { - if (Date.now() >= deadline) { - throw conflictReplacementError( - input.registration.pid, - 'the process did not exit after Maka requested a graceful stop', - ); - } - await wait(100, input.signal); - } -} - -function sameHostRegistration( - current: HostRegistration | undefined, - expected: HostRegistration, -): boolean { - return current?.rootId === expected.rootId && - current.hostEpoch === expected.hostEpoch && - current.pid === expected.pid && - current.endpoint === expected.endpoint; -} - -function signalRuntimeHostProcess(pid: number): void { - process.kill(pid, 'SIGTERM'); -} - -function processIsAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - return !isMissingProcessError(error); - } -} - -function isMissingProcessError(error: unknown): boolean { - return error instanceof Error && - 'code' in error && - (error as NodeJS.ErrnoException).code === 'ESRCH'; -} - -function waitForAbortableDelay(ms: number, signal: AbortSignal): Promise { - signal.throwIfAborted(); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - signal.removeEventListener('abort', onAbort); - resolve(); - }, ms); - const onAbort = () => { - clearTimeout(timer); - reject(signal.reason); - }; - signal.addEventListener('abort', onAbort, { once: true }); - }); -} - function conflictReplacementError(pid: number, reason: string): Error { return new Error(`Maka could not replace Runtime Host process ${pid}: ${reason}`); } diff --git a/apps/desktop/src/main/runtime-host-setup-package.ts b/apps/desktop/src/main/runtime-host-setup-package.ts index a0a03fee67..adfd04a85a 100644 --- a/apps/desktop/src/main/runtime-host-setup-package.ts +++ b/apps/desktop/src/main/runtime-host-setup-package.ts @@ -41,6 +41,18 @@ function isExactRuntimeHostSetupPackageSpecifier(value: unknown): value is strin return typeof value === 'string' && /^maka-agent@[0-9][0-9A-Za-z.+-]*$/u.test(value); } +export function runtimeHostSetupPackageVersion( + setupPackage: + | { readonly kind: 'npm'; readonly specifier: string } + | { readonly kind: 'development_archive' }, +): string | undefined { + if (setupPackage.kind === 'development_archive') return undefined; + if (!isExactRuntimeHostSetupPackageSpecifier(setupPackage.specifier)) { + throw new Error('Runtime Host setup package must use an exact Maka version'); + } + return setupPackage.specifier.slice('maka-agent@'.length); +} + interface DevelopmentArchiveBuild { readonly result: Promise; close(): Promise; diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts index 8698dcf9c9..fb4be4acca 100644 --- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts +++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts @@ -72,9 +72,10 @@ import type { DesktopRuntimeHostSshTerminalSnapshot, } from '../preload/bridge-contract.js'; import { createRuntimeHostFramedOutputFilter } from './runtime-host-framed-output.js'; -import type { - DesktopRuntimeHostDevelopmentPeerTarget, - DesktopRuntimeHostSetupPackage, +import { + runtimeHostSetupPackageVersion, + type DesktopRuntimeHostDevelopmentPeerTarget, + type DesktopRuntimeHostSetupPackage, } from './runtime-host-setup-package.js'; interface ActiveTerminal { @@ -1247,6 +1248,7 @@ function runtimeHostUpdateRemoteCommand( if (!input.expectedTarget.deploymentId) { throw new Error('Runtime Host update requires a deployment generation'); } + const targetVersion = runtimeHostSetupPackageVersion(setupPackage); return runtimeHostPackageRemoteCommand( setupPackage, [ @@ -1254,6 +1256,7 @@ function runtimeHostUpdateRemoteCommand( 'service', 'update', '--framed', + ...(targetVersion ? ['--target', targetVersion] : []), '--managed-root-id', input.expectedTarget.rootId, ...managedServiceTargetArgs(input.expectedTarget), diff --git a/apps/desktop/src/main/runtime-host-upgrade-copy.ts b/apps/desktop/src/main/runtime-host-upgrade-copy.ts index c00195729b..bcaa1a24ef 100644 --- a/apps/desktop/src/main/runtime-host-upgrade-copy.ts +++ b/apps/desktop/src/main/runtime-host-upgrade-copy.ts @@ -25,6 +25,12 @@ import type { } from './runtime-host-desktop-manager.js'; type Conflict = RuntimeHostRestartableConflict | RuntimeHostWaitConflict; +export type RuntimeHostUpgradeDialogDecision = 'restart' | 'replace' | 'wait' | 'cancel'; + +export interface RuntimeHostUpgradeDialog { + readonly options: MessageBoxOptions; + readonly decisions: readonly RuntimeHostUpgradeDialogDecision[]; +} type ActivityKey = | 'goal' | 'scheduledTask' @@ -34,38 +40,55 @@ type ActivityKey = | 'graph' | 'other'; -export function buildRuntimeHostUpgradeDialogOptions( +export function buildRuntimeHostUpgradeDialog( conflict: Conflict, - action: 'restart' | 'replace' | undefined, + availability: { + readonly action: 'restart' | 'replace' | undefined; + readonly canWait: boolean; + }, locale: UiLocale, -): MessageBoxOptions { +): RuntimeHostUpgradeDialog { const activity = conflict.handshake?.activity; const hasWork = (activity?.activeOperations ?? 0) > 0 || (activity?.residencies.length ?? 0) > 0; const copy = UPGRADE_COPY[locale]; - const canWait = conflict.registration.lifecycleMode !== 'service'; - const buttons = action - ? canWait - ? [action === 'restart' ? copy.restart : copy.replace, copy.wait, copy.cancel] - : [action === 'restart' ? copy.restart : copy.replace, copy.cancel] - : canWait - ? [copy.wait, copy.cancel] - : [copy.cancel]; + const choices: { readonly label: string; readonly decision: RuntimeHostUpgradeDialogDecision }[] = + []; + if (availability.action) { + choices.push({ + label: availability.action === 'restart' ? copy.restart : copy.replace, + decision: availability.action, + }); + } + if (availability.canWait) choices.push({ label: copy.wait, decision: 'wait' }); + choices.push({ label: copy.cancel, decision: 'cancel' }); + const defaultDecision = + availability.action === 'restart' && !hasWork + ? 'restart' + : availability.canWait + ? 'wait' + : 'cancel'; return { - type: 'warning', - title: copy.title, - message: copy.message, - detail: formatActivity(conflict, action, locale), - buttons, - defaultId: action === 'restart' && !hasWork ? 0 : action ? 1 : 0, - cancelId: action ? (canWait ? 2 : 1) : canWait ? 1 : 0, - noLink: true, + options: { + type: 'warning', + title: copy.title, + message: copy.message, + detail: formatActivity(conflict, availability, locale), + buttons: choices.map((choice) => choice.label), + defaultId: choices.findIndex((choice) => choice.decision === defaultDecision), + cancelId: choices.findIndex((choice) => choice.decision === 'cancel'), + noLink: true, + }, + decisions: choices.map((choice) => choice.decision), }; } function formatActivity( conflict: Conflict, - action: 'restart' | 'replace' | undefined, + availability: { + readonly action: 'restart' | 'replace' | undefined; + readonly canWait: boolean; + }, locale: UiLocale, ): string { const activity = conflict.handshake?.activity; @@ -81,12 +104,15 @@ function formatActivity( lines.push(`${copy.activity[activityKey(residency.label)]}: ${residency.count}`); } } else lines.push(copy.unknownActivity); - lines.push('', action === 'replace' ? copy.replaceWarning : copy.restartWarning); - if (action === 'replace') lines.push(copy.replaceExplanation); - else if (conflict.kind !== 'upgrade_required' || !conflict.restartable) { + if (availability.action === 'replace') { + lines.push('', copy.replaceWarning, copy.replaceExplanation); + } else if (availability.action === 'restart') { + lines.push('', copy.restartWarning); + } else if (conflict.kind !== 'upgrade_required' || !conflict.restartable) { + lines.push(''); lines.push(copy.exitOwner(conflict.registration.pid)); } - if (conflict.registration.lifecycleMode !== 'service') lines.push(copy.waitExplanation); + if (availability.canWait) lines.push(copy.waitExplanation); return lines.join('\n'); } diff --git a/apps/desktop/src/main/runtime-host-upgrade-dialog.ts b/apps/desktop/src/main/runtime-host-upgrade-dialog.ts index 68e08bf3ec..acda71f26e 100644 --- a/apps/desktop/src/main/runtime-host-upgrade-dialog.ts +++ b/apps/desktop/src/main/runtime-host-upgrade-dialog.ts @@ -24,7 +24,7 @@ import type { RuntimeHostUpgradePrompts, RuntimeHostNonRestartableDecision, } from './runtime-host-desktop-manager.js'; -import { buildRuntimeHostUpgradeDialogOptions } from './runtime-host-upgrade-copy.js'; +import { buildRuntimeHostUpgradeDialog } from './runtime-host-upgrade-copy.js'; export function createRuntimeHostUpgradePrompts( resolveLocale: () => Promise, @@ -37,28 +37,34 @@ export function createRuntimeHostUpgradePrompts( restartable: async (conflict): Promise => { const locale = await resolveLocale(); const canWait = conflict.registration.lifecycleMode !== 'service'; + const dialog = buildRuntimeHostUpgradeDialog( + conflict, + { action: 'restart', canWait }, + locale, + ); const { response } = await showDialog( - buildRuntimeHostUpgradeDialogOptions(conflict, 'restart', locale), + dialog.options, locale, ); - if (response === 0) return 'restart'; - if (canWait && response === 1) return 'wait'; - return 'cancel'; + const decision = dialog.decisions[response] ?? 'cancel'; + return decision === 'restart' || decision === 'wait' ? decision : 'cancel'; }, nonRestartable: async ( conflict, - canReplace, + actions, ): Promise => { const locale = await resolveLocale(); - const canWait = conflict.registration.lifecycleMode !== 'service'; + const dialog = buildRuntimeHostUpgradeDialog( + conflict, + { action: actions.canReplace ? 'replace' : undefined, canWait: actions.canWait }, + locale, + ); const { response } = await showDialog( - buildRuntimeHostUpgradeDialogOptions(conflict, canReplace ? 'replace' : undefined, locale), + dialog.options, locale, ); - if (!canReplace) return canWait && response === 0 ? 'wait' : 'cancel'; - if (response === 0) return 'replace'; - if (canWait && response === 1) return 'wait'; - return 'cancel'; + const decision = dialog.decisions[response] ?? 'cancel'; + return decision === 'replace' || decision === 'wait' ? decision : 'cancel'; }, }; } diff --git a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts index 99a637d83a..eaf67ceedf 100644 --- a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts +++ b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts @@ -164,7 +164,7 @@ describe('managed Runtime Host selected update', () => { assert.equal(updateInput?.sourcePackageRoot, '/managed/versions/2.0.0'); }); - it('keeps non-admitted candidates outside package acquisition and mutation', async () => { + it('requires a registration-bound confirmation for manual candidates', async () => { let output = ''; const exitCode = await runManagedRuntimeHostSelectedUpdateCli(OPTIONS, { resolveSelection: async () => @@ -181,6 +181,31 @@ describe('managed Runtime Host selected update', () => { const frame = decodeRuntimeHostServiceManagementFrame(output.trim()); assert.equal(exitCode, 1); assert.equal(frame?.kind === 'error' ? frame.error.code : undefined, 'update_not_admitted'); + + const selection = updateSelection({ + kind: 'manual_action', + reason: 'compatibility_mismatch', + }); + let updateInput: RuntimeHostUpdateCliOptions | undefined; + assert.equal( + await runManagedRuntimeHostSelectedUpdateCli( + { + ...OPTIONS, + expectedHost: { hostEpoch: 'older-host', pid: 42 }, + allowInterruptActiveTasks: true, + }, + { + resolveSelection: async () => selection, + withPackage: async (_candidate, use) => use('/verified/package'), + update: async (input) => { + updateInput = input; + return 0; + }, + }, + ), + 0, + ); + assert.deepEqual(updateInput?.expectedHost, { hostEpoch: 'older-host', pid: 42 }); }); }); diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts index be43b18155..90be275740 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -202,6 +202,37 @@ describe('managed Runtime Host service', () => { }, }, ); + assert.deepEqual( + parseRuntimeHostCommand([ + 'service', + 'update', + '--framed', + '--allow-interrupt-active-tasks', + '--target', + '0.2.0', + '--expected-host-json', + JSON.stringify({ hostEpoch: 'older-host', pid: 42 }), + '--expected-service-id', + 'b'.repeat(64), + '--expected-root-path', + '/srv/maka', + '--expected-root-id', + 'a'.repeat(64), + ]), + { + kind: 'runtime-host-service-update', + json: false, + framed: true, + expectedTarget: { + serviceId: 'b'.repeat(64), + rootPath: '/srv/maka', + rootId: 'a'.repeat(64), + }, + expectedHost: { hostEpoch: 'older-host', pid: 42 }, + selector: { kind: 'exact', version: '0.2.0' }, + allowInterruptActiveTasks: true, + }, + ); assert.equal(parseRuntimeHostCommand(['service', 'peer', 'disable']).kind, 'error'); for (const action of ['rotate', 'descriptor']) { assert.equal( diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index 085ecc51d6..cedb82178b 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -525,6 +525,7 @@ export async function runMakaCli( defaultRootPath: serviceDataRoots.workspaceRoot, selector: command.selector, expectedTarget: command.expectedTarget, + ...(command.expectedHost ? { expectedHost: command.expectedHost } : {}), ...(command.managedRootId ? { managedRootId: command.managedRootId } : {}), ...(command.operatorDeploymentId ? { operatorDeploymentId: command.operatorDeploymentId } @@ -541,6 +542,7 @@ export async function runMakaCli( ...(sourcePackageIntegrity ? { sourcePackageIntegrity } : {}), version, expectedTarget: command.expectedTarget, + ...(command.expectedHost ? { expectedHost: command.expectedHost } : {}), ...(command.managedRootId ? { managedRootId: command.managedRootId } : {}), ...(command.operatorDeploymentId ? { operatorDeploymentId: command.operatorDeploymentId } diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts index afaa84de78..acf0f44345 100644 --- a/packages/cli/src/runtime-host-cli.ts +++ b/packages/cli/src/runtime-host-cli.ts @@ -39,6 +39,11 @@ export type RuntimeHostUpdateSelector = | { readonly kind: 'channel'; readonly channel: 'latest' | 'next' } | { readonly kind: 'exact'; readonly version: string }; +export interface RuntimeHostExpectedHost { + readonly hostEpoch: string; + readonly pid: number; +} + export type RuntimeHostCliCommand = | { kind: 'runtime-host-managed-activate'; @@ -208,6 +213,7 @@ export type RuntimeHostCliCommand = managedRootId?: string; operatorDeploymentId?: string; expectedTarget: RuntimeHostManagedServiceTarget; + expectedHost?: RuntimeHostExpectedHost; selector?: RuntimeHostUpdateSelector; allowInterruptActiveTasks?: true; } @@ -725,6 +731,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { let allowInterruptActiveTasks = false; let clientDataRoot: string | undefined; let updateTarget: string | undefined; + let expectedHost: RuntimeHostExpectedHost | undefined; let expectedConfigFingerprint: string | undefined; const flagOptions: Readonly void | RuntimeHostCliError>> = action === 'uninstall' @@ -767,6 +774,16 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { }, } : {}), + ...(action === 'update' + ? { + '--expected-host-json': (value: string) => { + if (expectedHost !== undefined) return error('Duplicate --expected-host-json'); + const parsed = parseExpectedHost(value); + if ('kind' in parsed) return parsed; + expectedHost = parsed; + }, + } + : {}), ...(action === 'configure' ? { '--expected-config-fingerprint': (value: string) => { @@ -863,6 +880,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { ? { operatorDeploymentId: options.operatorDeploymentId } : {}), expectedTarget: options.expectedTarget!, + ...(expectedHost ? { expectedHost } : {}), ...(selector ? { selector } : {}), ...(allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}), }; @@ -1113,6 +1131,33 @@ function parseUpdateSelector( return { kind: 'exact', version: value }; } +function parseExpectedHost(value: string): RuntimeHostExpectedHost | RuntimeHostCliError { + let parsed: unknown; + try { + parsed = JSON.parse(value) as unknown; + } catch { + return error('--expected-host-json must be valid JSON'); + } + if ( + typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed) || + Object.keys(parsed).length !== 2 || + typeof (parsed as { hostEpoch?: unknown }).hostEpoch !== 'string' || + (parsed as { hostEpoch: string }).hostEpoch.length === 0 || + Buffer.byteLength((parsed as { hostEpoch: string }).hostEpoch, 'utf8') > 128 || + /[\u0000-\u001f\u007f]/u.test((parsed as { hostEpoch: string }).hostEpoch) || + !Number.isSafeInteger((parsed as { pid?: unknown }).pid) || + (parsed as { pid: number }).pid <= 0 + ) { + return error('--expected-host-json must contain one valid hostEpoch and pid'); + } + return { + hostEpoch: (parsed as { hostEpoch: string }).hostEpoch, + pid: (parsed as { pid: number }).pid, + }; +} + interface ManagedServiceOptions { readonly json: boolean; readonly framed?: true; diff --git a/packages/cli/src/runtime-host-lifecycle-transaction.ts b/packages/cli/src/runtime-host-lifecycle-transaction.ts index aa9d48e42f..bcca8cb1c8 100644 --- a/packages/cli/src/runtime-host-lifecycle-transaction.ts +++ b/packages/cli/src/runtime-host-lifecycle-transaction.ts @@ -81,7 +81,7 @@ export interface RuntimeHostLifecycleTransitionInput { export class RuntimeHostLifecycleTransactionError extends Error { constructor( - readonly code: 'transition_failed' | 'recovery_failed', + readonly code: 'transition_failed' | 'recovery_failed' | 'owner_changed', message: string, options?: ErrorOptions, ) { @@ -305,6 +305,7 @@ export async function retireRuntimeHostLifecycleOwner(input: { readonly rootPath: string; readonly rootId: string; readonly allowInterruptActiveTasks?: boolean; + readonly expectedOwner?: { readonly hostEpoch: string; readonly pid: number }; readonly supervisor?: { status(): Promise<{ readonly active: boolean; @@ -323,6 +324,10 @@ export async function retireRuntimeHostLifecycleOwner(input: { const idleOwner = await tryAcquireStateRootOwner(capability); if (idleOwner) { try { + if (input.expectedOwner && input.supervisor) { + const status = await input.supervisor.status(); + if (status.active) assertExpectedSupervisorOwner(input.expectedOwner, status); + } if (input.retireIdleSupervisor !== false) await input.supervisor?.retire(); return { kind: 'retired', owner: idleOwner }; } catch (error) { @@ -337,9 +342,14 @@ export async function retireRuntimeHostLifecycleOwner(input: { max: RUNTIME_HOST_PROTOCOL_VERSION, }, }); + assertExpectedRuntimeHostOwner( + input.expectedOwner, + 'registration' in connected ? connected.registration : undefined, + ); if (connected.kind !== 'connected') { if (input.allowInterruptActiveTasks && input.supervisor) { const status = await input.supervisor.status(); + assertExpectedSupervisorOwner(input.expectedOwner, status); if (status.active && status.pid !== null) { await input.supervisor.retire(); return waitForRuntimeHostLifecycleOwner(capability, input.timeoutMs ?? 45_000); @@ -353,6 +363,7 @@ export async function retireRuntimeHostLifecycleOwner(input: { try { const diagnostics = await connected.connection.request('host.diagnostics.query', {}); const supervisorStatus = await input.supervisor?.status(); + if (supervisorStatus) assertExpectedSupervisorOwner(input.expectedOwner, supervisorStatus); if ( supervisorStatus && (!supervisorStatus.active || supervisorStatus.pid !== diagnostics.pid) @@ -380,6 +391,32 @@ export async function retireRuntimeHostLifecycleOwner(input: { return waitForRuntimeHostLifecycleOwner(capability, input.timeoutMs ?? 45_000); } +function assertExpectedRuntimeHostOwner( + expected: { readonly hostEpoch: string; readonly pid: number } | undefined, + observed: { readonly hostEpoch: string; readonly pid: number } | undefined, +): void { + if (!expected) return; + if (!observed || observed.hostEpoch !== expected.hostEpoch || observed.pid !== expected.pid) { + throw new RuntimeHostLifecycleTransactionError( + 'owner_changed', + 'The Runtime Host changed after replacement was confirmed', + ); + } +} + +function assertExpectedSupervisorOwner( + expected: { readonly hostEpoch: string; readonly pid: number } | undefined, + observed: { readonly active: boolean; readonly pid: number | null }, +): void { + if (!expected) return; + if (!observed.active || observed.pid !== expected.pid) { + throw new RuntimeHostLifecycleTransactionError( + 'owner_changed', + 'The supervised Runtime Host changed after replacement was confirmed', + ); + } +} + async function waitForRuntimeHostLifecycleOwner( capability: Awaited>, timeoutMs: number, @@ -405,6 +442,7 @@ export async function replaceRuntimeHostLifecycle(input: { readonly current?: RuntimeHostManagedDeploymentConfig; readonly desired: RuntimeHostManagedDeploymentConfig; readonly allowInterruptActiveTasks?: boolean; + readonly expectedOwner?: { readonly hostEpoch: string; readonly pid: number }; readonly deps: RuntimeHostLifecycleTransactionDeps; readonly retirementSupervisor?: { status(): Promise<{ readonly active: boolean; readonly pid: number | null }>; @@ -431,6 +469,7 @@ export async function replaceRuntimeHostLifecycle(input: { ? { supervisor: currentProvider.supervisor } : {}), allowInterruptActiveTasks: input.allowInterruptActiveTasks ?? false, + ...(input.expectedOwner ? { expectedOwner: input.expectedOwner } : {}), }); if (retirement.kind === 'active_tasks') return retirement; await applyRetiredRuntimeHostLifecycleTransition({ diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index a920aa0709..7c97fb4ea1 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -74,11 +74,12 @@ import { RuntimeHostUpdatePackageError, withRuntimeHostRegistryUpdatePackage, } from './runtime-host-update-package.js'; -import type { RuntimeHostUpdateSelector } from './runtime-host-cli.js'; +import type { RuntimeHostExpectedHost, RuntimeHostUpdateSelector } from './runtime-host-cli.js'; import { canDiscardRuntimeHostLifecycleDesiredArtifacts, replaceRuntimeHostLifecycle, resolveRecoverableRuntimeHostManagedDeployment, + RuntimeHostLifecycleTransactionError, verifyRuntimeHostLifecycleProjection, type RuntimeHostLifecycleTransactionDeps, } from './runtime-host-lifecycle-transaction.js'; @@ -96,6 +97,7 @@ export interface RuntimeHostUpdateCliOptions { readonly sourcePackageIntegrity?: string; readonly version: string; readonly expectedTarget: RuntimeHostManagedServiceTarget; + readonly expectedHost?: RuntimeHostExpectedHost; readonly managedRootId?: string; readonly operatorDeploymentId?: string; readonly registrySelection?: { @@ -690,6 +692,7 @@ async function runCanonicalRuntimeHostUpdate( current, desired, allowInterruptActiveTasks: options.allowInterruptActiveTasks ?? false, + ...(options.expectedHost ? { expectedOwner: options.expectedHost } : {}), ...(desired.lifecycle.mode === 'on_demand' ? { activateDesired: async () => { @@ -754,7 +757,9 @@ async function runCanonicalRuntimeHostUpdate( error instanceof RuntimeHostManagedDeploymentError || error instanceof RuntimeHostDeploymentAuthorityError ? error.code - : 'update_incomplete'; + : error instanceof RuntimeHostLifecycleTransactionError && error.code === 'owner_changed' + ? 'target_mismatch' + : 'update_incomplete'; emit({ schemaVersion: 1, kind: 'error', @@ -839,7 +844,14 @@ export async function runManagedRuntimeHostResolvedUpdateCli( }; try { - if (selection.outcome.kind === 'manual_action') { + if ( + selection.outcome.kind === 'manual_action' && + !( + options.expectedHost && + options.allowInterruptActiveTasks && + selection.outcome.reason !== 'target_not_newer' + ) + ) { frameSink({ schemaVersion: 1, kind: 'error', From a032fc4193c387b730e4dea00e564a170c628e39 Mon Sep 17 00:00:00 2001 From: Wang Date: Sat, 29 Aug 2026 23:25:08 +0800 Subject: [PATCH 3/6] fix(runtime-host): reject stale replacement consent Generated-by: OpenAI Codex --- ...runtime-host-lifecycle-transaction.test.ts | 24 +++++++++++++++++++ .../src/runtime-host-lifecycle-transaction.ts | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/__tests__/runtime-host-lifecycle-transaction.test.ts b/packages/cli/src/__tests__/runtime-host-lifecycle-transaction.test.ts index ee4b541e2c..2f57af9c27 100644 --- a/packages/cli/src/__tests__/runtime-host-lifecycle-transaction.test.ts +++ b/packages/cli/src/__tests__/runtime-host-lifecycle-transaction.test.ts @@ -41,6 +41,7 @@ import { applyRuntimeHostLifecycleTransition, recoverRuntimeHostLifecycleTransition, replaceRuntimeHostLifecycle, + retireRuntimeHostLifecycleOwner, runtimeHostReconciliationTriggerDefinition, runtimeHostSupervisorDefinition, } from '../runtime-host-lifecycle-transaction.js'; @@ -464,6 +465,29 @@ test('interrupted activation compensation completes the previous semantics', asy provider.assertInstalled(compensation); }); +test('does not consume replacement consent after the supervised Host exits', async (t) => { + const stateRoot = await mkdtemp(join(tmpdir(), 'maka-lifecycle-stale-owner-')); + t.after(() => rm(stateRoot, { recursive: true, force: true })); + const capability = await resolveStorageRoot({ path: stateRoot, kind: 'interactive' }); + let retired = false; + + await assert.rejects( + retireRuntimeHostLifecycleOwner({ + rootPath: capability.canonicalPath, + rootId: capability.rootId, + expectedOwner: { hostEpoch: 'host-a', pid: 42 }, + supervisor: { + status: async () => ({ active: false, pid: null }), + retire: async () => { + retired = true; + }, + }, + }), + { code: 'owner_changed' }, + ); + assert.equal(retired, false); +}); + class FakeLifecycleProvider implements RuntimeHostLifecycleProvider { static failure: string | undefined; readonly supervisor; diff --git a/packages/cli/src/runtime-host-lifecycle-transaction.ts b/packages/cli/src/runtime-host-lifecycle-transaction.ts index bcca8cb1c8..d3d690cb04 100644 --- a/packages/cli/src/runtime-host-lifecycle-transaction.ts +++ b/packages/cli/src/runtime-host-lifecycle-transaction.ts @@ -326,7 +326,7 @@ export async function retireRuntimeHostLifecycleOwner(input: { try { if (input.expectedOwner && input.supervisor) { const status = await input.supervisor.status(); - if (status.active) assertExpectedSupervisorOwner(input.expectedOwner, status); + assertExpectedSupervisorOwner(input.expectedOwner, status); } if (input.retireIdleSupervisor !== false) await input.supervisor?.retire(); return { kind: 'retired', owner: idleOwner }; From 3518ed21246562f9e754e1436698813dbf422a21 Mon Sep 17 00:00:00 2001 From: Wang Date: Sat, 29 Aug 2026 23:33:53 +0800 Subject: [PATCH 4/6] fix(runtime-host): fence canonical replacement admission Generated-by: OpenAI Codex --- .../runtime-host-service-manager.test.ts | 18 +++++++++++++ packages/cli/src/runtime-host-cli.ts | 4 +++ .../src/runtime-host-lifecycle-transaction.ts | 26 +++++++++++++++++-- .../cli/src/runtime-host-update-command.ts | 12 +++++++++ 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts index 90be275740..5b3fe13ed9 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -117,6 +117,21 @@ describe('managed Runtime Host service', () => { websocketPort: 7443, }, ); + assert.equal( + parseRuntimeHostCommand([ + 'service', + 'update', + '--expected-host-json', + JSON.stringify({ hostEpoch: 'older-host', pid: 42 }), + '--expected-service-id', + 'b'.repeat(64), + '--expected-root-path', + '/srv/maka', + '--expected-root-id', + 'a'.repeat(64), + ]).kind, + 'error', + ); assert.deepEqual( parseRuntimeHostCommand([ 'service', @@ -218,6 +233,8 @@ describe('managed Runtime Host service', () => { '/srv/maka', '--expected-root-id', 'a'.repeat(64), + '--managed-root-id', + 'a'.repeat(64), ]), { kind: 'runtime-host-service-update', @@ -229,6 +246,7 @@ describe('managed Runtime Host service', () => { rootId: 'a'.repeat(64), }, expectedHost: { hostEpoch: 'older-host', pid: 42 }, + managedRootId: 'a'.repeat(64), selector: { kind: 'exact', version: '0.2.0' }, allowInterruptActiveTasks: true, }, diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts index acf0f44345..f05a3b0285 100644 --- a/packages/cli/src/runtime-host-cli.ts +++ b/packages/cli/src/runtime-host-cli.ts @@ -40,6 +40,7 @@ export type RuntimeHostUpdateSelector = | { readonly kind: 'exact'; readonly version: string }; export interface RuntimeHostExpectedHost { + /** Freshness fence for admitting a canonical supervised-deployment mutation. */ readonly hostEpoch: string; readonly pid: number; } @@ -867,6 +868,9 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { }; } if (action === 'update') { + if (expectedHost && !options.managedRootId) { + return error('--expected-host-json requires --managed-root-id'); + } const selector = updateTarget === undefined ? undefined : parseUpdateSelector(updateTarget, 'update'); if (selector && 'kind' in selector && selector.kind === 'error') return selector; diff --git a/packages/cli/src/runtime-host-lifecycle-transaction.ts b/packages/cli/src/runtime-host-lifecycle-transaction.ts index d3d690cb04..edb7f98c07 100644 --- a/packages/cli/src/runtime-host-lifecycle-transaction.ts +++ b/packages/cli/src/runtime-host-lifecycle-transaction.ts @@ -305,6 +305,10 @@ export async function retireRuntimeHostLifecycleOwner(input: { readonly rootPath: string; readonly rootId: string; readonly allowInterruptActiveTasks?: boolean; + /** + * Freshness fence evaluated before a canonical supervised-deployment retirement is admitted. + * The deployment lock and provider identity remain the mutation authority after admission. + */ readonly expectedOwner?: { readonly hostEpoch: string; readonly pid: number }; readonly supervisor?: { status(): Promise<{ @@ -316,6 +320,12 @@ export async function retireRuntimeHostLifecycleOwner(input: { readonly timeoutMs?: number; readonly retireIdleSupervisor?: boolean; }): Promise { + if (input.expectedOwner && !input.supervisor) { + throw new RuntimeHostLifecycleTransactionError( + 'owner_changed', + 'A Runtime Host identity fence requires a supervised deployment', + ); + } const capability = await resolveExistingStorageRoot({ path: input.rootPath, kind: 'interactive', @@ -373,6 +383,9 @@ export async function retireRuntimeHostLifecycleOwner(input: { 'The supervisor and State Root report different Runtime Host processes', ); } + // The exact Root owner and canonical supervisor now agree while the deployment lock is held. + // This admits retirement of that deployment; a later same-deployment restart is not a new + // authority, but it must not acquire the Root before the supervisor is retired. const prepared = await prepareConnectedRuntimeHostRetirement( connected.connection, input.allowInterruptActiveTasks ? 'interrupt_active_work' : 'refuse_active_work', @@ -384,11 +397,20 @@ export async function retireRuntimeHostLifecycleOwner(input: { 'The Runtime Host process changed while retirement was prepared', ); } - await input.supervisor?.retire(); + const retirement = await waitForRuntimeHostLifecycleOwner( + capability, + input.timeoutMs ?? 45_000, + ); + try { + await input.supervisor?.retire(); + return retirement; + } catch (error) { + await retirement.owner.close().catch(() => undefined); + throw error; + } } finally { await connected.connection.close().catch(() => undefined); } - return waitForRuntimeHostLifecycleOwner(capability, input.timeoutMs ?? 45_000); } function assertExpectedRuntimeHostOwner( diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index 7c97fb4ea1..80f29ebf45 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -208,6 +208,18 @@ export async function runManagedRuntimeHostUpdateCli( let retired = false; const emit = frameSink ?? ((frame: RuntimeHostUpdateFrame) => presentUpdateFrame(frame, options, deps)); + if (options.expectedHost && !options.managedRootId) { + emit({ + schemaVersion: 1, + kind: 'error', + action: 'update', + error: { + code: 'target_mismatch', + message: 'A Host identity fence requires canonical managed deployment authority', + }, + }); + return 1; + } if (options.managedRootId) { return runCanonicalRuntimeHostUpdate( { ...options, managedRootId: options.managedRootId }, From 2c0a652e2fc2b7909dbafe1aad5a007a815605d2 Mon Sep 17 00:00:00 2001 From: Wang Date: Sat, 29 Aug 2026 23:37:43 +0800 Subject: [PATCH 5/6] fix(runtime-host): fence interrupted update recovery Generated-by: OpenAI Codex --- packages/cli/src/runtime-host-lifecycle-transaction.ts | 2 ++ packages/cli/src/runtime-host-update-command.ts | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/runtime-host-lifecycle-transaction.ts b/packages/cli/src/runtime-host-lifecycle-transaction.ts index edb7f98c07..44e37cf465 100644 --- a/packages/cli/src/runtime-host-lifecycle-transaction.ts +++ b/packages/cli/src/runtime-host-lifecycle-transaction.ts @@ -204,6 +204,7 @@ export async function resolveRecoverableRuntimeHostManagedDeployment( readonly rootId: string; readonly deploymentId?: string; }; + readonly expectedOwner?: { readonly hostEpoch: string; readonly pid: number }; readonly ensureAvailable?: boolean; } = {}, ): Promise { @@ -227,6 +228,7 @@ export async function resolveRecoverableRuntimeHostManagedDeployment( : previousProvider ? { supervisor: previousProvider.supervisor } : {}), + ...(options.expectedOwner ? { expectedOwner: options.expectedOwner } : {}), retireIdleSupervisor: false, }); if (retirement.kind === 'active_tasks') { diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index 80f29ebf45..41111b4309 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -598,7 +598,10 @@ async function runCanonicalRuntimeHostUpdate( const recovered = await resolveRecoverableRuntimeHostManagedDeployment( options.managedRootId, lifecycleDeps, - { expectedTarget: options.expectedTarget }, + { + expectedTarget: options.expectedTarget, + ...(options.expectedHost ? { expectedOwner: options.expectedHost } : {}), + }, ); if (recovered.kind === 'absent') { throw new RuntimeHostServiceManagerError( From cb2b9577a46f5601436dcc541fb81b4b51fc2a20 Mon Sep 17 00:00:00 2001 From: Wang Date: Sun, 30 Aug 2026 01:13:37 +0800 Subject: [PATCH 6/6] fix(runtime-host): preserve WorkHub steering authority Generated-by: OpenAI Codex --- .../src/__tests__/message-coordinator.test.ts | 44 +++++++++++++++++-- .../src/server/execution-composition.ts | 13 +++--- .../src/server/message-coordinator.ts | 27 ++++++++++++ .../src/server/root-turn-coordinator.ts | 4 +- 4 files changed, 76 insertions(+), 12 deletions(-) diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index 576ee0d51f..240adfd9d4 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -55,7 +55,7 @@ import { SessionAdmissionGate } from '../server/session-admission-gate.js'; const ROOT = { sessionId: 'session-1', turnId: 'turn-1', runId: 'run-1' } as const; -test('consumes an atomically committed active-target admission exactly once', async (t) => { +test('consumes an active-target admission before the terminal transition can make it idle', async (t) => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-active-consume-')); const store = createSessionStore(root); t.after(async () => { @@ -125,10 +125,23 @@ test('consumes an atomically committed active-target admission exactly once', as }, }); - await fixture.coordinator.consumePendingAdmissions([ROOT.sessionId]); - await fixture.coordinator.consumePendingAdmissions([ROOT.sessionId]); + const admitted = deferred(); + const releaseAdmission = deferred(); + const consume = fixture.sessionAdmission.run(ROOT.sessionId, async (lease) => { + admitted.resolve(undefined); + await releaseAdmission.promise; + await fixture.coordinator.consumePendingAdmissionsAdmitted(ROOT.sessionId, lease); + }); + await admitted.promise; + const terminal = fixture.sessionAdmission.run(ROOT.sessionId, () => { + fixture.setRootState({ kind: 'idle' }); + }); + releaseAdmission.resolve(undefined); + await consume; + await terminal; assert.equal(fixture.coordinator.projection(ROOT.sessionId).steering.length, 1); + assert.equal(fixture.recoveredBatches.length, 0); assert.equal(fixture.drainRequests(), 0); }); @@ -149,7 +162,7 @@ test('idle recovery resolves differently preassigned Messages to their shared su submittedContentDigest: messageContentDigest(content), submittedPlacement: 'current_turn', placement: 'current_turn', - disposition: 'steering', + disposition: 'followup', admittedAt: 10, }); } @@ -184,6 +197,29 @@ test('idle recovery resolves differently preassigned Messages to their shared su }); }); +test('idle recovery preserves the exact root identity of durable steering', async () => { + const fixture = createFixture(); + fixture.setRootState({ kind: 'idle' }); + const content = { text: 'recover exact steering' }; + await fixture.admissions.commitMessageAdmission({ + ...ROOT, + messageId: 'workhub-message', + content, + submittedContentDigest: messageContentDigest(content), + submittedPlacement: 'current_turn', + placement: 'current_turn', + disposition: 'steering', + admittedAt: 10, + }); + + await fixture.coordinator.consumePendingAdmissions([ROOT.sessionId]); + + assert.deepEqual(fixture.recoveredBatches[0]?.rootIdentity, { + turnId: ROOT.turnId, + runId: ROOT.runId, + }); +}); + test('idle submit starts exactly one root Turn and retry identity is connection-independent', async () => { const fixture = createFixture(); fixture.setRootState({ kind: 'idle' }); diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 1f59857dff..cd1bb2b91e 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1280,6 +1280,9 @@ export async function createExecutionRuntimeHostComposition( sessionActions: { assign: async (input) => { const durable = await stores.sessionStore.readWorkHubAssignment(input.actionId); + if (durable) { + await messages.consumePendingAdmissions([durable.targetSessionId]); + } const create = !durable && input.create ? await sessionCatalog.prepareWorkHubCreate({ @@ -1348,6 +1351,10 @@ export async function createExecutionRuntimeHostComposition( }, ...(create ? { create } : {}), }); + // Keep the durable steering identity and its live queue owner + // under one Session admission. A terminal transition must not + // observe the committed Message before the queue does. + await messages.consumePendingAdmissionsAdmitted(input.targetSessionId, lease); try { await continuityCoordinator.refreshCanonical( WORKHUB_COORDINATION_SESSION_ID, @@ -1362,12 +1369,6 @@ export async function createExecutionRuntimeHostComposition( }, )); - // Assignment is already the acknowledged durable outcome. Consume - // the exact stored admission after releasing the assignment leases; - // failure leaves it pending for the same normal recovery consumer. - void messages - .consumePendingAdmissions([persisted.targetSessionId]) - .catch(() => undefined); return { turnId: persisted.targetTurnId, ...(persisted.steered ? { steered: true as const } : {}), diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 8df4f86848..8d116cb494 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -129,6 +129,8 @@ export interface HostMessageRecoveryBatch { readonly content: MessageContent; readonly submittedContent: MessageContent; readonly sources: readonly RootTurnSourceMessage[]; + /** Steering is bound to the exact root identity chosen before it became durable. */ + readonly rootIdentity?: Pick; /** * What the recovered Message asked of its Turn. Only a lone Message can * carry one — exact-Turn intent needs an idle Session and opens its own root @@ -738,6 +740,16 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { } } + /** Consume pending admissions while the caller still owns this Session's admission lease. */ + consumePendingAdmissionsAdmitted( + sessionId: string, + admission: SessionAdmissionLease, + ): Promise { + return this.#sessionAdmission.runAdmitted(sessionId, admission, () => + this.#consumePendingAdmissions(sessionId, admission), + ); + } + async #consumePendingAdmissions( sessionId: string, admissionLease: SessionAdmissionLease, @@ -796,6 +808,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { content: aggregateMessageContents(pending.map((entry) => entry.content)), submittedContent: aggregateMessageContents(pending.map((entry) => entry.content)), sources: pending.map(pendingMessageSource), + ...pendingSteeringRootIdentity(pending), ...(pending.length === 1 && pending[0]!.submittedIntent ? { submittedIntent: pending[0]!.submittedIntent } : {}), @@ -2296,6 +2309,20 @@ function pendingMessageSource(admission: PendingMessageAdmission): RootTurnSourc }; } +function pendingSteeringRootIdentity( + pending: readonly PendingMessageAdmission[], +): Pick { + const steering = pending.filter((entry) => entry.disposition === 'steering'); + const first = steering[0]; + if (!first) return {}; + if (steering.some((entry) => entry.turnId !== first.turnId || entry.runId !== first.runId)) { + throw new RuntimeMessageAuthorityInvariantError( + 'Pending steering admissions disagree on their root identity', + ); + } + return { rootIdentity: { turnId: first.turnId, runId: first.runId } }; +} + function submittedProjectionContent(content: MessageContent): MessageContent { const normalized = normalizeMessageContent(content); const text = normalized.displayText ?? normalized.text; diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index d631a5d990..2406f4ee1c 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -1170,7 +1170,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { const reservation = this.reserveRootTurn(input.sessionId); if (!reservation) return { error: 'Another root Turn is being admitted' }; try { - const turnId = randomUUID(); + const turnId = input.rootIdentity?.turnId ?? randomUUID(); // The recovered Message asked for this mode before the Host stopped; // admitting without it would run a different Turn than was requested. const turnOrchestration = input.submittedIntent?.turnOrchestration; @@ -1178,7 +1178,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { const admitted = await this.rootAdmissionOwner.admitRootTurn({ sessionId: input.sessionId, turnId, - proposedRunId: randomUUID(), + proposedRunId: input.rootIdentity?.runId ?? randomUUID(), proposedUserMessageId: input.sources.length === 1 ? input.sources[0]!.messageId : null, execution: { kind: 'external_message',