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..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 @@ -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,41 @@ 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 observed = upgradeRequired(false); + const conflict = { + ...observed, + registration: { ...observed.registration, lifecycleMode: 'service' as const }, + }; + const replacement = candidateHarness(); + let starts = 0; + let replaced: typeof observed.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, actions) => { + assert.deepEqual(actions, { canReplace: true, canWait: false }); + return 'replace'; + }, + }, + resolveLocalHostReplacement: async (registration) => ({ + replace: async () => { + 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,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'), - waitOnly: 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 b4155e9947..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,8 +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), - '--operator-deployment-id', deploymentId, + '--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 40583de49e..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 @@ -23,6 +23,12 @@ 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'; @@ -228,6 +234,65 @@ test('keeps the managed service visible when Direct peer support is unavailable' assert.equal(snapshot.managedService, true); }); +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 }); + 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' }), + 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 { + 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()); + + const replacement = await service.resolveConflictingHostReplacement( + hostRegistration({ rootId, lifecycleMode: 'service' }), + new AbortController().signal, + ); + assert.ok(replacement); + await replacement.replace(); + assert.equal(updated, true); +}); + 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 })); @@ -309,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, @@ -738,6 +804,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..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,11 +461,9 @@ 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.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..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,8 @@ 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 = { kind: 'upgrade_required', @@ -39,8 +40,16 @@ 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 = 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); @@ -49,4 +58,51 @@ 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('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 }; + }, + ); + assert.equal( + await prompts.nonRestartable( + { + kind: 'upgrade_required', + restartable: false, + registration: { pid: 42 }, + } as never, + { canReplace: true, canWait: 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, { 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 4dd1639eab..e002cadeb8 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), + 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 474a0d872c..82d4cc97ca 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -136,7 +136,16 @@ export class DesktopLocalHostRetirementError extends Error { } export type RuntimeHostRestartDecision = 'restart' | 'wait' | 'cancel'; -export type RuntimeHostWaitDecision = '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() { @@ -170,7 +179,10 @@ export interface RuntimeHostUpgradePrompts { restartable( conflict: RuntimeHostRestartableConflict, ): Promise; - waitOnly(conflict: RuntimeHostWaitConflict): Promise; + nonRestartable( + conflict: RuntimeHostWaitConflict, + actions: RuntimeHostNonRestartableActions, + ): Promise; } interface DesktopRuntimeHostTargetGeneration { @@ -210,6 +222,10 @@ export async function startRuntimeHostDesktopManager( registration: HostRegistration, signal: AbortSignal, ) => Promise; + resolveLocalHostReplacement?: ( + registration: HostRegistration, + signal: AbortSignal, + ) => Promise; recoverLocalHost?: (signal: AbortSignal) => Promise; reconnectBackoff?: RuntimeHostReconnectBackoff; pairingFinalizationTimeoutMs?: number; @@ -226,6 +242,7 @@ export async function startRuntimeHostDesktopManager( options.upgradePrompts, options.waitForHostExit ?? waitForProcessExit, options.waitForHostRetirement ?? waitForProcessRetirement, + options.resolveLocalHostReplacement, options.recoverLocalHost, options.reconnectBackoff, options.pairingFinalizationTimeoutMs ?? DEFAULT_PAIRING_FINALIZATION_TIMEOUT_MS, @@ -266,6 +283,12 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { registration: HostRegistration, signal: AbortSignal, ) => Promise, + private readonly resolveLocalHostReplacement: + | (( + registration: HostRegistration, + signal: AbortSignal, + ) => Promise) + | undefined, private readonly recoverLocalHost: | ((signal: AbortSignal) => Promise) | undefined, @@ -853,8 +876,25 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { result.kind === 'incompatible' || (result.kind === 'upgrade_required' && !result.restartable) ) { - const decision = await this.#resolveWaitOnly(result); + 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 (!replacement) { + throw new RuntimeHostPermanentReconnectError( + 'This Runtime Host cannot be replaced from the current target', + ); + } + await replacement.replace(); + takeoverHostEpoch = undefined; + continue; + } takeoverHostEpoch = undefined; await this.waitForHostRetirement(result.registration, signal); continue; @@ -871,8 +911,11 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { return this.#missingUpgradePrompt(); } - #resolveWaitOnly(conflict: RuntimeHostWaitConflict): Promise { - if (this.upgradePrompts) return this.upgradePrompts.waitOnly(conflict); + #resolveNonRestartable( + conflict: RuntimeHostWaitConflict, + actions: RuntimeHostNonRestartableActions, + ): Promise { + 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 e227c2dfe5..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; }, @@ -346,11 +350,11 @@ 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); + const targetVersion = runtimeHostSetupPackageVersion(command.setupPackage); return runServiceFrameProcess({ command: { executable: 'npm', @@ -365,10 +369,12 @@ export function createDesktopRuntimeHostLocalOperator(input: { 'service', 'update', '--framed', + ...(targetVersion ? ['--target', targetVersion] : []), '--managed-root-id', command.target.rootId, - '--operator-deployment-id', - deploymentId, + ...(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 275aacde91..49bc2b76a7 100644 --- a/apps/desktop/src/main/runtime-host-local-remote-access.ts +++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts @@ -27,7 +27,7 @@ import { encodeRuntimeHostOwnerConnectionCode, } from '@maka/runtime-host/client'; import { resolveRuntimeHostManagedDeploymentAuthority } from '@maka/runtime-host/operator'; -import { REMOTE_OWNER_OPERATION_GRANTS } from '@maka/runtime-host/protocol'; +import { REMOTE_OWNER_OPERATION_GRANTS, type HostRegistration } from '@maka/runtime-host/protocol'; import type { DesktopLocalRuntimeHostRemoteAccessEnableResult, DesktopLocalRuntimeHostRemoteAccessSnapshot, @@ -80,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 @@ -100,6 +104,10 @@ export interface DesktopLocalRuntimeHostRemoteAccess { changeManaged( operation: (target: DesktopRuntimeHostLocalManagementTarget) => Promise, ): Promise; + resolveConflictingHostReplacement( + registration: HostRegistration, + signal: AbortSignal, + ): Promise<{ replace(): Promise } | undefined>; recoverBeforeLocalHostStart(signal?: AbortSignal): Promise; recover(): Promise; close(): Promise; @@ -172,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, @@ -663,6 +672,58 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { const managed = requireManagementTarget(lifecycle); return requireManager(input.manager).runManagedLocalHostChange(() => operation(managed)); }), + 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(); @@ -735,6 +796,10 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { }; } +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-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 8222ce8e9d..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 { @@ -1244,10 +1245,10 @@ 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'); } + const targetVersion = runtimeHostSetupPackageVersion(setupPackage); return runtimeHostPackageRemoteCommand( setupPackage, [ @@ -1255,10 +1256,9 @@ function runtimeHostUpdateRemoteCommand( 'service', 'update', '--framed', + ...(targetVersion ? ['--target', targetVersion] : []), '--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..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,32 +40,61 @@ type ActivityKey = | 'graph' | 'other'; -export function buildRuntimeHostUpgradeDialogOptions( +export function buildRuntimeHostUpgradeDialog( conflict: Conflict, - canRestart: boolean, + 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 buttons = canRestart ? [copy.restart, copy.wait, copy.cancel] : [copy.wait, 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, locale), - buttons, - defaultId: hasWork || !canRestart ? (canRestart ? 1 : 0) : 0, - cancelId: canRestart ? 2 : 1, - 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, locale: UiLocale): string { +function formatActivity( + conflict: Conflict, + availability: { + readonly action: 'restart' | 'replace' | undefined; + readonly canWait: boolean; + }, + 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 +104,15 @@ 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); + 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 (availability.canWait) lines.push(copy.waitExplanation); return lines.join('\n'); } @@ -93,15 +134,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 +160,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..acda71f26e 100644 --- a/apps/desktop/src/main/runtime-host-upgrade-dialog.ts +++ b/apps/desktop/src/main/runtime-host-upgrade-dialog.ts @@ -22,9 +22,9 @@ 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'; +import { buildRuntimeHostUpgradeDialog } from './runtime-host-upgrade-copy.js'; export function createRuntimeHostUpgradePrompts( resolveLocale: () => Promise, @@ -36,21 +36,35 @@ export function createRuntimeHostUpgradePrompts( return { 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, true, locale), + dialog.options, locale, ); - if (response === 0) return 'restart'; - if (response === 1) return 'wait'; - return 'cancel'; + const decision = dialog.decisions[response] ?? 'cancel'; + return decision === 'restart' || decision === 'wait' ? decision : 'cancel'; }, - waitOnly: async (conflict): Promise => { + nonRestartable: async ( + conflict, + actions, + ): Promise => { const locale = await resolveLocale(); + const dialog = buildRuntimeHostUpgradeDialog( + conflict, + { action: actions.canReplace ? 'replace' : undefined, canWait: actions.canWait }, + locale, + ); const { response } = await showDialog( - buildRuntimeHostUpgradeDialogOptions(conflict, false, locale), + dialog.options, locale, ); - return response === 0 ? 'wait' : 'cancel'; + const decision = dialog.decisions[response] ?? 'cancel'; + return decision === 'replace' || decision === 'wait' ? decision : 'cancel'; }, }; } 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/__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..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', @@ -202,6 +217,40 @@ 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), + '--managed-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 }, + managedRootId: 'a'.repeat(64), + 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 0dd8e4c268..cedb82178b 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; @@ -521,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 } @@ -534,8 +539,10 @@ export async function runMakaCli( clientDataRoot: serviceDataRoots.clientDataRoot, defaultRootPath: serviceDataRoots.workspaceRoot, sourcePackageRoot: fileURLToPath(new URL('..', import.meta.url)), + ...(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..f05a3b0285 100644 --- a/packages/cli/src/runtime-host-cli.ts +++ b/packages/cli/src/runtime-host-cli.ts @@ -39,6 +39,12 @@ export type RuntimeHostUpdateSelector = | { readonly kind: 'channel'; readonly channel: 'latest' | 'next' } | { readonly kind: 'exact'; readonly version: string }; +export interface RuntimeHostExpectedHost { + /** Freshness fence for admitting a canonical supervised-deployment mutation. */ + readonly hostEpoch: string; + readonly pid: number; +} + export type RuntimeHostCliCommand = | { kind: 'runtime-host-managed-activate'; @@ -208,6 +214,7 @@ export type RuntimeHostCliCommand = managedRootId?: string; operatorDeploymentId?: string; expectedTarget: RuntimeHostManagedServiceTarget; + expectedHost?: RuntimeHostExpectedHost; selector?: RuntimeHostUpdateSelector; allowInterruptActiveTasks?: true; } @@ -725,6 +732,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 +775,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) => { @@ -850,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; @@ -863,6 +884,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { ? { operatorDeploymentId: options.operatorDeploymentId } : {}), expectedTarget: options.expectedTarget!, + ...(expectedHost ? { expectedHost } : {}), ...(selector ? { selector } : {}), ...(allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}), }; @@ -1113,6 +1135,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 69802ed8f1..44e37cf465 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, ) { @@ -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') { @@ -305,6 +307,11 @@ 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<{ readonly active: boolean; @@ -315,6 +322,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', @@ -323,6 +336,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(); + assertExpectedSupervisorOwner(input.expectedOwner, status); + } if (input.retireIdleSupervisor !== false) await input.supervisor?.retire(); return { kind: 'retired', owner: idleOwner }; } catch (error) { @@ -337,7 +354,19 @@ 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); + } + } throw new RuntimeHostLifecycleTransactionError( 'transition_failed', `Runtime Host cannot prepare for retirement: ${connected.kind}`, @@ -346,6 +375,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) @@ -355,6 +385,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', @@ -366,11 +399,53 @@ 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); } - const deadline = Date.now() + (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, +): Promise> { + const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const owner = await tryAcquireStateRootOwner(capability); if (owner) return { kind: 'retired', owner }; @@ -391,6 +466,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 }>; @@ -417,6 +493,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({ @@ -646,23 +723,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 +766,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..41111b4309 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -74,11 +74,13 @@ 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'; import { manageRuntimeHostManagedLifecycle } from './runtime-host-managed-lifecycle-manager.js'; @@ -92,8 +94,10 @@ export interface RuntimeHostUpdateCliOptions { readonly clientDataRoot: string; readonly defaultRootPath: string; readonly sourcePackageRoot: string; + readonly sourcePackageIntegrity?: string; readonly version: string; readonly expectedTarget: RuntimeHostManagedServiceTarget; + readonly expectedHost?: RuntimeHostExpectedHost; readonly managedRootId?: string; readonly operatorDeploymentId?: string; readonly registrySelection?: { @@ -204,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 }, @@ -248,7 +264,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 +344,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 +598,10 @@ async function runCanonicalRuntimeHostUpdate( const recovered = await resolveRecoverableRuntimeHostManagedDeployment( options.managedRootId, lifecycleDeps, - { expectedTarget: options.expectedTarget, ensureAvailable: true }, + { + expectedTarget: options.expectedTarget, + ...(options.expectedHost ? { expectedOwner: options.expectedHost } : {}), + }, ); if (recovered.kind === 'absent') { throw new RuntimeHostServiceManagerError( @@ -588,6 +610,7 @@ async function runCanonicalRuntimeHostUpdate( ); } const current = recovered.config; + await verifyRuntimeHostLifecycleProjection(current, lifecycleDeps); assertRuntimeHostManagedOperatorConfig( current, options.operatorDeploymentId, @@ -606,6 +629,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 +637,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; @@ -683,6 +707,7 @@ async function runCanonicalRuntimeHostUpdate( current, desired, allowInterruptActiveTasks: options.allowInterruptActiveTasks ?? false, + ...(options.expectedHost ? { expectedOwner: options.expectedHost } : {}), ...(desired.lifecycle.mode === 'on_demand' ? { activateDesired: async () => { @@ -747,7 +772,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', @@ -832,7 +859,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', 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',