From be7b963ee402c40a2ebb42aefa61de4607d5c2c9 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 19 Aug 2026 21:30:25 +0800 Subject: [PATCH 1/3] feat(cli): reconcile local Runtime Host generations Generated-by: Codex --- .../runtime-host-cli-context.test.ts | 150 ++++++++++++++++-- .../runtime-host-installation-context.test.ts | 75 +++++++++ packages/cli/src/cli-core.ts | 15 +- packages/cli/src/runtime-host-cli-context.ts | 118 ++++++++++++-- .../src/runtime-host-installation-context.ts | 58 +++++++ .../runtime-host-installation-provenance.ts | 26 +++ .../cli/src/runtime-host-service-manager.ts | 10 +- packages/cli/src/runtime-host-tui-command.ts | 30 +++- packages/cli/src/runtime-host-tui-context.ts | 6 +- .../src/__tests__/host-kernel.test.ts | 89 ++++++++++- .../src/client/connect-or-spawn.ts | 16 +- 11 files changed, 543 insertions(+), 50 deletions(-) create mode 100644 packages/cli/src/__tests__/runtime-host-installation-context.test.ts create mode 100644 packages/cli/src/runtime-host-installation-context.ts create mode 100644 packages/cli/src/runtime-host-installation-provenance.ts diff --git a/packages/cli/src/__tests__/runtime-host-cli-context.test.ts b/packages/cli/src/__tests__/runtime-host-cli-context.test.ts index 8593f557fa..c4df2a5975 100644 --- a/packages/cli/src/__tests__/runtime-host-cli-context.test.ts +++ b/packages/cli/src/__tests__/runtime-host-cli-context.test.ts @@ -40,15 +40,18 @@ import { type HostIncompatible, } from '@maka/runtime-host/protocol'; import { + canRestartRuntimeHostCliConflict, connectRuntimeHostCli, + resolveRuntimeHostCliConflictDecision, RuntimeHostCliConflictError, - shouldRetryRuntimeHostConflict, } from '../runtime-host-cli-context.js'; const V0_1_11_HOST_COMPATIBILITY_EPOCH = 25; test('CLI Runtime Host bootstrap launches the execution composition', async () => { let candidateEntrypoint: string | URL | undefined; + let candidateGeneration: string | undefined; + let requiredGeneration: string | undefined; let clientInstanceId: string | undefined; let closes = 0; const connection = { @@ -74,6 +77,8 @@ test('CLI Runtime Host bootstrap launches the execution composition', async () = { connectOrSpawn: async (input) => { candidateEntrypoint = input.candidateEntrypoint; + candidateGeneration = input.candidateGeneration; + requiredGeneration = input.generation; clientInstanceId = input.clientInstanceId; return { kind: 'connected', @@ -86,11 +91,19 @@ test('CLI Runtime Host bootstrap launches the execution composition', async () = defaultTarget: null, connections: [], }), + loadInstallationContext: async () => ({ + packageRoot: '/maka-cli', + version: '1.2.3', + installationScope: 'persistent', + artifactGeneration: 'maka-agent@1.2.3', + }), }, ); assert.ok(candidateEntrypoint instanceof URL); assert.equal(basename(fileURLToPath(candidateEntrypoint)), 'execution-candidate-main.js'); + assert.equal(candidateGeneration, 'maka-agent@1.2.3'); + assert.equal(requiredGeneration, undefined); assert.ok(clientInstanceId); await context.close(); assert.equal(closes, 1); @@ -132,7 +145,7 @@ test('non-interactive CLI reports how to retire an incompatible Runtime Host', a ); assert.match( error.message, - /ephemeral Host is not currently idle and cannot be replaced by this Client/, + /ephemeral Host still owns this State Root/, ); assert.match(error.message, /previous compatible Maka build/); return true; @@ -172,15 +185,93 @@ test('CLI explains a service Host without inventing resident work', async () => ); }); -test('Runtime Host conflict waits only after an explicit wait answer', () => { - assert.equal(shouldRetryRuntimeHostConflict('w'), true); - assert.equal(shouldRetryRuntimeHostConflict(' wait '), true); - assert.equal(shouldRetryRuntimeHostConflict(' W '), true); - assert.equal(shouldRetryRuntimeHostConflict('WAIT'), true); - assert.equal(shouldRetryRuntimeHostConflict(''), false); - assert.equal(shouldRetryRuntimeHostConflict('c'), false); - assert.equal(shouldRetryRuntimeHostConflict('cancel'), false); - assert.equal(shouldRetryRuntimeHostConflict('unexpected'), false); +test('CLI local generation takeover is exact and remains a typed conflict while blocked', async () => { + let connectInput: + | { + readonly generation?: string; + readonly candidateGeneration?: string; + readonly takeoverHostEpoch?: string; + } + | undefined; + await assert.rejects( + connectRuntimeHostCli( + { + rootPath: '/runtime-host-root', + surface: 'tui', + localGenerationRequest: { + kind: 'takeover', + expectedHostEpoch: 'host-old', + }, + }, + { + loadInstallationContext: async () => ({ + packageRoot: '/maka-cli', + version: '1.2.3', + installationScope: 'persistent', + artifactGeneration: 'maka-agent@1.2.3', + }), + connectOrSpawn: async (input) => { + connectInput = input; + return { + kind: 'upgrade_required', + restartable: false, + registration: hostRegistration({ generation: 'maka-agent@1.2.2' }), + handshake: { + kind: 'incompatible', + hostEpoch: 'host-old', + protocolMin: 0, + protocolMax: 0, + compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH, + compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + compositionRevision: 'current', + generation: 'maka-agent@1.2.2', + state: 'ready', + replacement: 'blocked_by_residency', + activity: { + connections: 1, + activeOperations: 0, + processUptimeSeconds: 61, + residencies: [{ label: 'scheduled-task', count: 1 }], + }, + }, + }; + }, + }, + ), + (error: unknown) => { + assert.ok(error instanceof RuntimeHostCliConflictError); + assert.equal(error.conflict.kind, 'upgrade_required'); + assert.equal(canRestartRuntimeHostCliConflict(error), false); + assert.match(error.message, /1 connection\(s\), 0 active operation\(s\), uptime 61s/); + assert.match(error.message, /scheduled-task \(1\)/); + return true; + }, + ); + + assert.equal(connectInput?.generation, 'maka-agent@1.2.3'); + assert.equal(connectInput?.candidateGeneration, 'maka-agent@1.2.3'); + assert.equal(connectInput?.takeoverHostEpoch, 'host-old'); +}); + +test('CLI conflict decisions require an offered restart action', () => { + assert.equal(resolveRuntimeHostCliConflictDecision('r', true), 'restart'); + assert.equal(resolveRuntimeHostCliConflictDecision(' restart ', true), 'restart'); + assert.equal(resolveRuntimeHostCliConflictDecision('r', false), 'cancel'); + assert.equal(resolveRuntimeHostCliConflictDecision('w', true), 'wait'); + assert.equal(resolveRuntimeHostCliConflictDecision(' WAIT ', false), 'wait'); + assert.equal(resolveRuntimeHostCliConflictDecision('', true), 'cancel'); + assert.equal(resolveRuntimeHostCliConflictDecision('unexpected', true), 'cancel'); +}); + +test('temporary npx invocation cannot turn a restartable Host fact into replacement authority', () => { + const error = new RuntimeHostCliConflictError( + { kind: 'upgrade_required', restartable: true }, + hostRegistration({ generation: 'maka-agent@1.2.2' }), + false, + ); + + assert.equal(canRestartRuntimeHostCliConflict(error), false); + assert.match(error.message, /transient CLI invocation is not a persistent installation owner/u); }); test('CLI reports an actionable stored-data startup failure', async () => { @@ -269,6 +360,8 @@ test('remote CLI profiles pin root identity and resolve credential outside the p }, }, loadClientInstanceId: async () => '11111111-1111-4111-8111-111111111111', + loadInstallationContext: async () => + assert.fail('remote profile must not resolve local installation'), readConnectionCatalog: async () => ({ revision: 1, defaultTarget: null, connections: [] }), }, ); @@ -281,6 +374,40 @@ test('remote CLI profiles pin root identity and resolve credential outside the p await context.close(); }); +test('remote CLI profiles reject local generation intent before connecting', async (t) => { + const clientDataRoot = await mkdtemp(join(tmpdir(), 'maka-cli-remote-generation-root-')); + t.after(() => rm(clientDataRoot, { recursive: true, force: true })); + await createClientRuntimeHostProfileCatalog(clientDataRoot).save( + { + id: 'office', + name: 'Office', + kind: 'remote', + transport: { kind: 'tls', url: 'wss://runtime.example.com/runtime-host' }, + rootId: 'c'.repeat(64), + }, + 'opaque-token', + ); + + await assert.rejects( + connectRuntimeHostCli( + { + rootPath: '/unused-local-root', + clientDataRoot, + surface: 'tui', + profileId: 'office', + localGenerationRequest: { kind: 'require_installed' }, + }, + { + connectOrSpawn: async () => assert.fail('remote profile must not use local discovery'), + connectRemoteProfile: async () => assert.fail('local generation intent must be rejected'), + loadInstallationContext: async () => + assert.fail('remote profile must not resolve local installation'), + }, + ), + /remote Runtime Host does not accept local generation requests/u, + ); +}); + test('remote CLI profile state and Client identity use the explicit Client Data Root', async (t) => { const clientDataRoot = await mkdtemp(join(tmpdir(), 'maka-cli-client-root-')); t.after(() => rm(clientDataRoot, { recursive: true, force: true })); @@ -480,6 +607,7 @@ function hostRegistration( overrides: Partial<{ compatibilityEpoch: number; lifecycleMode: 'ephemeral' | 'service'; + generation: string; }> = {}, ) { return { diff --git a/packages/cli/src/__tests__/runtime-host-installation-context.test.ts b/packages/cli/src/__tests__/runtime-host-installation-context.test.ts new file mode 100644 index 0000000000..c54a173055 --- /dev/null +++ b/packages/cli/src/__tests__/runtime-host-installation-context.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { test } from 'node:test'; +import { resolveRuntimeHostCliInstallationContext } from '../runtime-host-installation-context.js'; + +test('published CLI package version is its stable Runtime Host artifact generation', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-cli-release-context-')); + t.after(() => rm(root, { recursive: true, force: true })); + const manifestUrl = pathToFileURL(join(root, 'package.json')); + await writeFile(manifestUrl, JSON.stringify({ name: 'maka-agent', version: '1.2.3' })); + + const context = await resolveRuntimeHostCliInstallationContext({ + manifestUrl, + }); + + assert.deepEqual(context, { + packageRoot: fileURLToPath(new URL('.', manifestUrl)), + version: '1.2.3', + installationScope: 'persistent', + artifactGeneration: 'maka-agent@1.2.3', + }); +}); + +test('development CLI process receives a distinct explicit artifact generation', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-cli-development-context-')); + t.after(() => rm(root, { recursive: true, force: true })); + const manifestUrl = pathToFileURL(join(root, 'package.json')); + await writeFile( + manifestUrl, + JSON.stringify({ name: 'maka-agent', version: '1.2.3', private: true }), + ); + + const context = await resolveRuntimeHostCliInstallationContext({ + manifestUrl, + developmentId: 'dev-process', + }); + + assert.equal(context.packageRoot, fileURLToPath(new URL('.', manifestUrl))); + assert.equal(context.version, '1.2.3'); + assert.equal(context.installationScope, 'persistent'); + assert.equal(context.artifactGeneration, 'maka-agent@1.2.3+development.dev-process'); +}); + +test('temporary npx package can identify a candidate but cannot replace a persistent Host', async (t) => { + const cacheRoot = await mkdtemp(join(tmpdir(), 'maka-cli-npx-context-')); + t.after(() => rm(cacheRoot, { recursive: true, force: true })); + const packageRoot = join(cacheRoot, '_npx', 'hash', 'node_modules', 'maka-agent'); + await mkdir(packageRoot, { recursive: true }); + const manifestUrl = pathToFileURL(join(packageRoot, 'package.json')); + await writeFile(manifestUrl, JSON.stringify({ name: 'maka-agent', version: '1.2.3' })); + + const context = await resolveRuntimeHostCliInstallationContext({ + manifestUrl, + environment: { npm_config_cache: cacheRoot }, + homeDir: join(cacheRoot, 'home'), + }); + + assert.equal(context.installationScope, 'temporary_npx'); + assert.equal(context.artifactGeneration, 'maka-agent@1.2.3'); +}); + +test('CLI installation context rejects a different package manifest', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-cli-invalid-context-')); + t.after(() => rm(root, { recursive: true, force: true })); + const manifestUrl = pathToFileURL(join(root, 'package.json')); + await writeFile(manifestUrl, JSON.stringify({ name: 'not-maka', version: '1.2.3' })); + + await assert.rejects( + resolveRuntimeHostCliInstallationContext({ manifestUrl }), + /installation manifest is invalid/, + ); +}); diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index 0e931db9a3..24756dcec2 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -21,6 +21,9 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { deriveMakaDataRoots, resolveMakaDataRoots } from './workspace-root.js'; +import { join } from 'node:path'; +import { formatMakaResumeHint } from './cli-invocation.js'; +import { loadRuntimeHostCliInstallationContext } from './runtime-host-installation-context.js'; import { parseRuntimeHostCommand, type RuntimeHostCliCommand } from './runtime-host-cli.js'; import { resolveCliUiLocale } from './cli-ui-locale.js'; @@ -45,7 +48,6 @@ export interface MakaCliLaunchOptions { readonly cliCommand: string; readonly capabilityProviderIdentityScope: 'legacy-home' | 'client-data-root'; } - export const RELEASE_MAKA_CLI_LAUNCH_OPTIONS = { dataProfileName: 'Maka', cliCommand: 'maka', @@ -197,7 +199,8 @@ export async function runMakaCli( argv: string[] = process.argv.slice(2), options: MakaCliLaunchOptions = RELEASE_MAKA_CLI_LAUNCH_OPTIONS, ): Promise { - const version = await readPackageVersion(); + const installation = await loadRuntimeHostCliInstallationContext(); + const version = installation.version; const command = parseMakaCliArgs(argv, version, options.cliCommand); const dataRoots = resolveMakaDataRoots({ profileName: options.dataProfileName }); switch (command.kind) { @@ -237,7 +240,7 @@ export async function runMakaCli( json: command.json, clientDataRoot: dataRoots.clientDataRoot, defaultRootPath: dataRoots.workspaceRoot, - sourcePackageRoot: fileURLToPath(new URL('..', import.meta.url)), + sourcePackageRoot: installation.packageRoot, version, principalId: command.principalId, preset: command.preset, @@ -493,12 +496,6 @@ function parseTuiArgs(argv: string[]): MakaCliCommand { }; } -async function readPackageVersion(): Promise { - const raw = await readFile(new URL('../package.json', import.meta.url), 'utf8'); - const parsed = JSON.parse(raw) as { version?: unknown }; - return typeof parsed.version === 'string' ? parsed.version : '0.0.0'; -} - export function launchMakaCli(options: MakaCliLaunchOptions): void { runMakaCli(process.argv.slice(2), options).then( (code) => { diff --git a/packages/cli/src/runtime-host-cli-context.ts b/packages/cli/src/runtime-host-cli-context.ts index 46463187b4..55e3ac13aa 100644 --- a/packages/cli/src/runtime-host-cli-context.ts +++ b/packages/cli/src/runtime-host-cli-context.ts @@ -45,6 +45,10 @@ import { type HostIncompatible, } from '@maka/runtime-host/protocol'; import { resolveMakaClientDataRoot } from '@maka/storage'; +import { + loadRuntimeHostCliInstallationContext, + type RuntimeHostCliInstallationContext, +} from './runtime-host-installation-context.js'; /** * The mode a new Session starts in belongs to the Host: `session.create` @@ -72,14 +76,25 @@ export class RuntimeHostCliConflictError extends RuntimeHostPermanentReconnectEr readonly code = 'RUNTIME_HOST_RESTART_REQUIRED'; constructor( - readonly handshake: HostIncompatible, - registration: HostRegistration, + readonly conflict: + | { readonly kind: 'incompatible'; readonly handshake: HostIncompatible } + | { + readonly kind: 'upgrade_required'; + readonly restartable: boolean; + readonly handshake?: HostIncompatible; + }, + readonly registration: HostRegistration, + readonly canReplaceLocalHost: boolean, ) { - super(formatRuntimeHostCliConflict(handshake, registration)); + super(formatRuntimeHostCliConflict(conflict, registration, canReplaceLocalHost)); this.name = 'RuntimeHostCliConflictError'; } } +export type RuntimeHostCliLocalGenerationRequest = + | { readonly kind: 'require_installed' } + | { readonly kind: 'takeover'; readonly expectedHostEpoch: string }; + export interface RuntimeHostCliConnectionContext { readonly connection: RuntimeHostConnection; readonly catalog: ConnectionCatalogSnapshot; @@ -98,6 +113,7 @@ interface RuntimeHostCliContextDeps { readonly readConnectionCatalog: typeof readRuntimeHostConnectionCatalog; readonly loadClientInstanceId: typeof loadOrCreateRuntimeHostClientInstanceId; readonly executionCandidateEntrypoint: URL; + readonly loadInstallationContext: () => Promise; readonly profileCatalog?: RuntimeHostProfileCatalog; } @@ -107,6 +123,7 @@ export async function connectRuntimeHostCli( readonly profileId?: string; readonly clientDataRoot?: string; readonly interactiveSsh?: boolean; + readonly localGenerationRequest?: RuntimeHostCliLocalGenerationRequest; }, overrides: Partial = {}, ): Promise { @@ -118,10 +135,15 @@ export async function connectRuntimeHostCli( executionCandidateEntrypoint: new URL( import.meta.resolve('@maka/runtime-host/execution-candidate-main'), ), + loadInstallationContext: loadRuntimeHostCliInstallationContext, ...overrides, }; const resolvedProfile = await resolveHostProfile(input, deps); const profile = resolvedProfile.profile; + if (profile.kind === 'remote' && input.localGenerationRequest) { + throw new TypeError('A remote Runtime Host does not accept local generation requests'); + } + const installation = profile.kind === 'local' ? await deps.loadInstallationContext() : undefined; const clientInstanceId = profile.kind === 'local' ? randomUUID() @@ -134,6 +156,13 @@ export async function connectRuntimeHostCli( clientInstanceId, compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, candidateEntrypoint: deps.executionCandidateEntrypoint, + ...(installation ? { candidateGeneration: installation.artifactGeneration } : {}), + ...(installation && input.localGenerationRequest + ? { generation: installation.artifactGeneration } + : {}), + ...(input.localGenerationRequest?.kind === 'takeover' + ? { takeoverHostEpoch: input.localGenerationRequest.expectedHostEpoch } + : {}), } as const; const connect = async ( signal?: AbortSignal, @@ -153,11 +182,21 @@ export async function connectRuntimeHostCli( ...(signal ? { signal } : {}), }); if (connected.kind === 'incompatible') { - throw new RuntimeHostCliConflictError(connected.handshake, connected.registration); + throw new RuntimeHostCliConflictError( + { kind: 'incompatible', handshake: connected.handshake }, + connected.registration, + installation?.installationScope === 'persistent', + ); } if (connected.kind === 'upgrade_required') { - throw new RuntimeHostPermanentReconnectError( - 'RUNTIME_HOST_RESTART_REQUIRED: An older Runtime Host build is still running. Restart it, or wait for its background work to finish.', + throw new RuntimeHostCliConflictError( + { + kind: 'upgrade_required', + restartable: connected.restartable, + ...(connected.handshake ? { handshake: connected.handshake } : {}), + }, + connected.registration, + installation?.installationScope === 'persistent', ); } if (connected.kind === 'failed') { @@ -199,15 +238,16 @@ async function resolveHostProfile( } function formatRuntimeHostCliConflict( - handshake: HostIncompatible, + conflict: RuntimeHostCliConflictError['conflict'], registration: HostRegistration, + canReplaceLocalHost: boolean, ): string { const lines = [ - 'RUNTIME_HOST_RESTART_REQUIRED: An older Runtime Host is still running and cannot accept this client.', + 'RUNTIME_HOST_RESTART_REQUIRED: A different Runtime Host is still running and cannot accept this client.', `Local Runtime Host: PID ${registration.pid}; lifecycle ${registration.lifecycleMode ?? 'unknown'}; compatibility epoch ${registration.compatibilityEpoch}.`, ]; if (registration.lifecycleMode === 'ephemeral') { - lines.push('The ephemeral Host is not currently idle and cannot be replaced by this Client.'); + lines.push('The ephemeral Host still owns this State Root.'); } else if (registration.lifecycleMode === 'service') { lines.push( 'This service Host is managed by its operator and cannot be replaced by this Client.', @@ -215,23 +255,67 @@ function formatRuntimeHostCliConflict( } else { lines.push('This Host cannot be replaced by this Client.'); } - if (handshake.compatibilityEpoch < RUNTIME_HOST_COMPATIBILITY_EPOCH) { + if (!canReplaceLocalHost && registration.lifecycleMode === 'ephemeral') { lines.push( - registration.lifecycleMode === 'service' - ? 'Use the service operator to inspect or upgrade the Host.' - : 'Use a previous compatible Maka build to inspect the Host and finish or clear any retained work. Stop the Host only after deciding that interruption is safe.', + 'This transient CLI invocation is not a persistent installation owner and cannot replace the local Host.', ); - } else { + } + const activity = conflict.handshake?.activity; + if (activity) { + lines.push( + `Host activity: ${activity.connections} connection(s), ${activity.activeOperations} active operation(s), uptime ${activity.processUptimeSeconds}s.`, + ); + if (activity.residencies.length > 0) { + lines.push( + `Durable residency: ${activity.residencies + .map(({ label, count }) => `${label} (${count})`) + .join(', ')}.`, + ); + } lines.push( - `Host protocol ${handshake.protocolMin}-${handshake.protocolMax}; CLI protocol ${RUNTIME_HOST_PROTOCOL_VERSION}.`, + 'Restarting preserves durable state, but it can interrupt in-flight external work.', ); } + if (conflict.kind === 'incompatible') { + if (conflict.handshake.compatibilityEpoch < RUNTIME_HOST_COMPATIBILITY_EPOCH) { + lines.push( + registration.lifecycleMode === 'service' + ? 'Use the service operator to inspect or upgrade the Host.' + : canReplaceLocalHost + ? 'Restart only if interruption is acceptable. To inspect retained work first, use a previous compatible Maka build.' + : 'Use a persistent Maka installation or a previous compatible build to inspect and replace this Host.', + ); + } else { + lines.push( + `Host protocol ${conflict.handshake.protocolMin}-${conflict.handshake.protocolMax}; CLI protocol ${RUNTIME_HOST_PROTOCOL_VERSION}.`, + ); + lines.push( + registration.lifecycleMode === 'service' + ? 'Use the service operator to select compatible Client and Host builds.' + : 'Use a newer compatible Maka build to inspect this Host.', + ); + } + } return lines.join('\n'); } -export function shouldRetryRuntimeHostConflict(answer: string): boolean { +export type RuntimeHostCliConflictDecision = 'restart' | 'wait' | 'cancel'; + +export function resolveRuntimeHostCliConflictDecision( + answer: string, + canRestart: boolean, +): RuntimeHostCliConflictDecision { const normalized = answer.trim().toLowerCase(); - return normalized === 'w' || normalized === 'wait'; + if (canRestart && (normalized === 'r' || normalized === 'restart')) return 'restart'; + return normalized === 'w' || normalized === 'wait' ? 'wait' : 'cancel'; +} + +export function canRestartRuntimeHostCliConflict(error: RuntimeHostCliConflictError): boolean { + if (!error.canReplaceLocalHost || error.registration.lifecycleMode !== 'ephemeral') return false; + const activity = error.conflict.handshake?.activity; + return error.conflict.kind === 'upgrade_required' + ? error.conflict.restartable + : activity !== undefined && activity.connections === 0; } export function resolveRuntimeHostCliTarget( diff --git a/packages/cli/src/runtime-host-installation-context.ts b/packages/cli/src/runtime-host-installation-context.ts new file mode 100644 index 0000000000..f10237bd9b --- /dev/null +++ b/packages/cli/src/runtime-host-installation-context.ts @@ -0,0 +1,58 @@ +import { randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { isTemporaryNpxInstallation } from './runtime-host-installation-provenance.js'; + +const PACKAGE_NAME = 'maka-agent'; +const developmentGenerationId = randomUUID(); + +export interface RuntimeHostCliInstallationContext { + readonly packageRoot: string; + readonly version: string; + readonly installationScope: 'persistent' | 'temporary_npx'; + readonly artifactGeneration: string; +} + +export async function resolveRuntimeHostCliInstallationContext( + options: { + readonly manifestUrl?: URL; + readonly developmentId?: string; + readonly environment?: NodeJS.ProcessEnv; + readonly homeDir?: string; + } = {}, +): Promise { + const manifestUrl = options.manifestUrl ?? new URL('../package.json', import.meta.url); + const manifest = JSON.parse(await readFile(manifestUrl, 'utf8')) as { + name?: unknown; + version?: unknown; + private?: unknown; + }; + if (manifest.name !== PACKAGE_NAME || typeof manifest.version !== 'string') { + throw new Error('The Maka CLI installation manifest is invalid'); + } + const packageRoot = fileURLToPath(new URL('.', manifestUrl)); + const provenance = manifest.private === true ? 'development' : 'release'; + const installationScope = (await isTemporaryNpxInstallation(packageRoot, { + environment: options.environment ?? process.env, + homeDir: options.homeDir ?? homedir(), + })) + ? 'temporary_npx' + : 'persistent'; + return { + packageRoot, + version: manifest.version, + installationScope, + artifactGeneration: + provenance === 'release' + ? `${PACKAGE_NAME}@${manifest.version}` + : `${PACKAGE_NAME}@${manifest.version}+development.${options.developmentId ?? developmentGenerationId}`, + }; +} + +let defaultInstallationContext: Promise | undefined; + +export function loadRuntimeHostCliInstallationContext(): Promise { + defaultInstallationContext ??= resolveRuntimeHostCliInstallationContext(); + return defaultInstallationContext; +} diff --git a/packages/cli/src/runtime-host-installation-provenance.ts b/packages/cli/src/runtime-host-installation-provenance.ts new file mode 100644 index 0000000000..15c33182ef --- /dev/null +++ b/packages/cli/src/runtime-host-installation-provenance.ts @@ -0,0 +1,26 @@ +import { realpath } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + +export async function isTemporaryNpxInstallation( + path: string, + input: { + readonly environment: NodeJS.ProcessEnv; + readonly homeDir: string; + }, +): Promise { + const canonicalPath = await realpath(path).catch(() => resolve(path)); + const cacheRoots = await Promise.all( + [input.environment.npm_config_cache, join(input.homeDir, '.npm')].flatMap((root) => + root ? [realpath(resolve(root, '_npx')).catch(() => resolve(root, '_npx'))] : [], + ), + ); + return cacheRoots.some((root) => isWithin(root, canonicalPath)); +} + +function isWithin(root: string, candidate: string): boolean { + const pathFromRoot = relative(root, candidate); + return ( + pathFromRoot === '' || + (pathFromRoot !== '..' && !pathFromRoot.startsWith(`..${sep}`) && !isAbsolute(pathFromRoot)) + ); +} diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts index d6b73d196a..7b88683f20 100644 --- a/packages/cli/src/runtime-host-service-manager.ts +++ b/packages/cli/src/runtime-host-service-manager.ts @@ -46,6 +46,7 @@ import { removeRuntimeHostManagedDeployment, resolveRuntimeHostManagedDeploymentForCli, } from './runtime-host-managed-deployment.js'; +import { isTemporaryNpxInstallation } from './runtime-host-installation-provenance.js'; const SERVICE_CONFIG_FILE = 'runtime-host-service.json'; const SERVICE_LIFECYCLE_LOCK_FILE = 'runtime-host-setup'; @@ -910,12 +911,7 @@ async function assertPersistentCliInstallation( environment: NodeJS.ProcessEnv, homeDir: string, ): Promise { - const cacheRoots = await Promise.all( - [environment.npm_config_cache, join(homeDir, '.npm')].flatMap((root) => - root ? [realpath(resolve(root, '_npx')).catch(() => resolve(root, '_npx'))] : [], - ), - ); - if (cacheRoots.some((root) => isWithin(root, cliPath))) { + if (await isTemporaryNpxInstallation(cliPath, { environment, homeDir })) { throw new RuntimeHostServiceManagerError( 'invalid_launch', 'A persistent Runtime Host service cannot use a temporary npx installation; install Maka globally and retry', @@ -978,6 +974,8 @@ export async function verifyRuntimeHostManagedServiceReady( ); } +export const waitForManagedRuntimeHostReady = verifyRuntimeHostManagedServiceReady; + async function prepareRuntimeHostRetirement( config: RuntimeHostManagedServiceConfig, expectedPid: number, diff --git a/packages/cli/src/runtime-host-tui-command.ts b/packages/cli/src/runtime-host-tui-command.ts index b74150e17d..151f881bd8 100644 --- a/packages/cli/src/runtime-host-tui-command.ts +++ b/packages/cli/src/runtime-host-tui-command.ts @@ -25,9 +25,11 @@ import { readRuntimeHostConnectionCatalog } from '@maka/runtime-host/client'; import { createForeignSessionStore } from '@maka/storage'; import { formatMakaResumeHint } from './cli-invocation.js'; import { + canRestartRuntimeHostCliConflict, connectRuntimeHostCli, + resolveRuntimeHostCliConflictDecision, RuntimeHostCliConflictError, - shouldRetryRuntimeHostConflict, + type RuntimeHostCliLocalGenerationRequest, } from './runtime-host-cli-context.js'; import { createRuntimeHostOnboardingSurface } from './runtime-host-onboarding.js'; import type { MakaPiTuiTurnActivitySurface } from './pi-tui-contracts.js'; @@ -124,18 +126,38 @@ export async function runRuntimeHostTui(input: RunRuntimeHostTuiInput): Promise< async function createTuiContextWithHostConflictPrompt( input: Parameters[0], ): Promise> | null> { + let generationRequest: RuntimeHostCliLocalGenerationRequest | undefined; while (true) { try { - return await createRuntimeHostTuiContext(input); + return await createRuntimeHostTuiContext({ + ...input, + ...(generationRequest ? { localGenerationRequest: generationRequest } : {}), + }); } catch (error) { if (!(error instanceof RuntimeHostCliConflictError) || !process.stdin.isTTY) throw error; + if (!generationRequest && error.registration.lifecycleMode === 'ephemeral') { + generationRequest = { kind: 'require_installed' }; + continue; + } process.stderr.write(`${error.message}\n`); + const canRestart = canRestartRuntimeHostCliConflict(error); const readline = createInterface({ input: process.stdin, output: process.stderr }); try { const answer = await readline.question( - 'Wait only if the existing Host is expected to become idle, or cancel? [w/C] ', + canRestart + ? 'Restart this local Host now, wait for it to exit, or cancel? [r/w/C] ' + : 'Wait only if the existing Host is expected to exit, or cancel? [w/C] ', ); - if (!shouldRetryRuntimeHostConflict(answer)) return null; + const decision = resolveRuntimeHostCliConflictDecision(answer, canRestart); + if (decision === 'cancel') return null; + if (decision === 'restart') { + generationRequest = { + kind: 'takeover', + expectedHostEpoch: error.registration.hostEpoch, + }; + continue; + } + generationRequest = undefined; } finally { readline.close(); } diff --git a/packages/cli/src/runtime-host-tui-context.ts b/packages/cli/src/runtime-host-tui-context.ts index e19ad9f4b0..5643d21723 100644 --- a/packages/cli/src/runtime-host-tui-context.ts +++ b/packages/cli/src/runtime-host-tui-context.ts @@ -40,6 +40,7 @@ import { connectRuntimeHostCli, readHostChatDefaultPermissionMode, resolveRuntimeHostCliTarget, + type RuntimeHostCliLocalGenerationRequest, } from './runtime-host-cli-context.js'; import type { MakaPiTuiTurnActivitySurface, @@ -83,7 +84,6 @@ export interface RuntimeHostTuiContext { readonly profile: RuntimeHostProfile; close(): Promise; } - export interface CreateRuntimeHostTuiContextInput { readonly clientDataRoot: string; readonly rootPath: string; @@ -91,6 +91,7 @@ export interface CreateRuntimeHostTuiContextInput { readonly resumeSessionId?: string; readonly hostProfileId?: string; readonly projectId?: string; + readonly localGenerationRequest?: RuntimeHostCliLocalGenerationRequest; } export async function createRuntimeHostTuiContext( @@ -101,6 +102,9 @@ export async function createRuntimeHostTuiContext( rootPath: input.rootPath, interactiveSsh: true, ...(input.hostProfileId ? { profileId: input.hostProfileId } : {}), + ...(input.localGenerationRequest + ? { localGenerationRequest: input.localGenerationRequest } + : {}), }); const connection = connected.connection; try { diff --git a/packages/runtime-host/src/__tests__/host-kernel.test.ts b/packages/runtime-host/src/__tests__/host-kernel.test.ts index d9f31bda78..4be0bb1f3b 100644 --- a/packages/runtime-host/src/__tests__/host-kernel.test.ts +++ b/packages/runtime-host/src/__tests__/host-kernel.test.ts @@ -157,6 +157,94 @@ function diagnosticRegistration(state: 'ready' | 'draining') { } describe('non-serving Runtime Host kernel', () => { + test('rejects divergent required and candidate generations before election', async () => { + await assert.rejects( + connectOrSpawnRuntimeHostWithDependencies( + { + rootPath: '/not-resolved', + surface: 'tui', + protocol: CURRENT_PROTOCOL, + compositionId: KERNEL_COMPOSITION.descriptor.id, + candidateEntrypoint: KERNEL_CANDIDATE_ENTRYPOINT, + generation: 'maka-agent@1.2.3', + candidateGeneration: 'maka-agent@1.2.4', + }, + { + random: () => 0.5, + launchCandidate: () => { + throw new Error('must not launch'); + }, + }, + ), + /generation and candidateGeneration must match/u, + ); + }); + + test('candidate generation does not reject a compatible existing Host generation', async () => { + await withHostPaths(async (paths) => { + const existing = await startTestRuntimeHostCandidate(paths, { + rootPath: paths.root, + generation: 'desktop-existing', + idleGraceMs: 10_000, + }); + assert.equal(existing.kind, 'winner'); + if (existing.kind !== 'winner') return; + + let launches = 0; + const connected = await connectOrSpawnRuntimeHostWithDependencies( + { + rootPath: paths.root, + surface: 'tui', + protocol: CURRENT_PROTOCOL, + compositionId: KERNEL_COMPOSITION.descriptor.id, + candidateEntrypoint: KERNEL_CANDIDATE_ENTRYPOINT, + candidateGeneration: 'maka-agent@1.2.3', + electionDeadlineMs: 1_000, + }, + { + random: () => 0.5, + launchCandidate: () => { + launches += 1; + return { spawned: new Promise(() => undefined) }; + }, + }, + ); + + assert.equal(connected.kind, 'connected'); + if (connected.kind === 'connected') { + assert.equal(connected.registration.generation, 'desktop-existing'); + await connected.connection.close(); + } + assert.equal(launches, 0); + }); + }); + + test('candidate generation is published by a newly elected Host', async () => { + await withHostPaths(async (paths) => { + const connected = await connectOrSpawnRuntimeHostWithDependencies( + { + rootPath: paths.root, + surface: 'tui', + protocol: CURRENT_PROTOCOL, + compositionId: KERNEL_COMPOSITION.descriptor.id, + candidateEntrypoint: KERNEL_CANDIDATE_ENTRYPOINT, + candidateGeneration: 'maka-agent@1.2.3', + electionDeadlineMs: 5_000, + }, + { + random: () => 0.5, + launchCandidate: (input) => launchTestRuntimeHostCandidate(paths, input), + }, + ); + + assert.equal(connected.kind, 'connected'); + if (connected.kind === 'connected') { + assert.equal(connected.registration.generation, 'maka-agent@1.2.3'); + await connected.connection.close(); + } + }); + }); + test('reports a recovery failure when the election produces no ready Host', async () => { await withHostPaths(async (paths) => { const result = await connectOrSpawnRuntimeHostWithDependencies( @@ -3057,7 +3145,6 @@ function testComposition( ...overrides, }; } - interface HostPaths { base: string; root: string; diff --git a/packages/runtime-host/src/client/connect-or-spawn.ts b/packages/runtime-host/src/client/connect-or-spawn.ts index 9b7f3b63c2..aafce4a4a4 100644 --- a/packages/runtime-host/src/client/connect-or-spawn.ts +++ b/packages/runtime-host/src/client/connect-or-spawn.ts @@ -27,6 +27,7 @@ import { performance } from 'node:perf_hooks'; import { requireClientInstanceId, requireHostCompositionId, + requireHostGeneration, validateProtocolRange, type HostRegistration, type HostIncompatible, @@ -67,6 +68,7 @@ export interface ConnectOrSpawnRuntimeHostInput { protocol: ProtocolRange; compositionId: string; generation?: string; + candidateGeneration?: string; takeoverHostEpoch?: string; clientInstanceId?: string; electionDeadlineMs?: number; @@ -275,6 +277,18 @@ export async function connectOrSpawnRuntimeHostWithDependencies( input: ConnectOrSpawnRuntimeHostInput, dependencies: ConnectOrSpawnRuntimeHostDependencies, ): Promise { + if ( + input.generation !== undefined && + input.candidateGeneration !== undefined && + input.generation !== input.candidateGeneration + ) { + throw new TypeError('Runtime Host generation and candidateGeneration must match'); + } + const candidateGenerationInput = input.candidateGeneration ?? input.generation; + const candidateGeneration = + candidateGenerationInput === undefined + ? undefined + : requireHostGeneration(candidateGenerationInput); const deadlineMs = input.electionDeadlineMs ?? electionDeadlineMsFromEnvironment( @@ -408,7 +422,7 @@ export async function connectOrSpawnRuntimeHostWithDependencies( expectedRootId: capability.rootId, entrypoint: input.candidateEntrypoint, initialConnectionTimeoutMs: Math.ceil(remaining), - ...(input.generation === undefined ? {} : { generation: input.generation }), + ...(candidateGeneration === undefined ? {} : { generation: candidateGeneration }), }); candidateLaunches.add(launch); const attempt = await settleBeforeDeadline(launch.spawned, deadline, input.signal); From 3580910105bd2eca0d96344cfdf204aa75f98cd6 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 19 Aug 2026 21:30:31 +0800 Subject: [PATCH 2/3] docs(runtime-host): define installation lifecycle Generated-by: Codex --- ...ntime-host-installation-lifecycle-draft.md | 305 ++++++++++++++++++ ...host-installation-lifecycle-draft.zh-CN.md | 305 ++++++++++++++++++ ...on-lifecycle-simplification-audit.zh-CN.md | 144 +++++++++ 3 files changed, 754 insertions(+) create mode 100644 docs/architecture/runtime-host-installation-lifecycle-draft.md create mode 100644 docs/architecture/runtime-host-installation-lifecycle-draft.zh-CN.md create mode 100644 docs/architecture/runtime-host-installation-lifecycle-simplification-audit.zh-CN.md diff --git a/docs/architecture/runtime-host-installation-lifecycle-draft.md b/docs/architecture/runtime-host-installation-lifecycle-draft.md new file mode 100644 index 0000000000..e60b62e5dc --- /dev/null +++ b/docs/architecture/runtime-host-installation-lifecycle-draft.md @@ -0,0 +1,305 @@ +--- +doc_id: architecture.runtime-host-installation-lifecycle +title: "Runtime Host Installation and Update Lifecycle" +language: en +source_language: zh-CN +counterpart: ./runtime-host-installation-lifecycle-draft.zh-CN.md +implementation_status: mixed +document_status: draft +translation_status: synced +last_verified: 2026-08-19 +owners: + - maka-backend +--- + +# Runtime Host installation and update lifecycle + +> Core question: when Desktop, an installed CLI, or an `npx` invocation changes independently while a local Runtime Host still owns durable work, which component chooses the Host artifact, which component retires the old process, and how does Maka avoid either losing work or leaving the user unable to start? + +This draft extends the stable [Runtime Host architecture](./runtime-host-architecture.md). It defines an ownership model for #3231 and its CLI, `npx`, and TUI children. It does not turn a package manager into a Runtime authority, and it does not make a remote service Client-updatable. + +## Status language + +- **Current** describes behavior implemented in the referenced source on 2026-08-19. +- **Planned** describes the proposed shared contract needed by the tracking issue. +- **Exploratory** identifies a product or compatibility decision that is not settled by this draft. + +## The problem in one concrete upgrade + +Assume an epoch-24 CLI started a local Host and a durable Scheduled Task keeps that Host resident. The user then installs an epoch-25 CLI and starts it. + +The old and new processes answer four different questions: + +| Value | Question it answers | It must not mean | +|---|---|---| +| `compatibilityEpoch` | Can this Client and Host safely speak the Domain contract? | Which installed package should own the next Host process | +| Host Epoch | Which process currently holds the State Root writer lease? | Product or package version | +| Host Generation | Which exact local-owner artifact requested this Host process? | General compatibility for every connected Surface | +| Package release | What version did npm or Desktop distribute? | A protocol guarantee or a live-process identity | + +If the compatibility epochs differ, the new Client cannot operate the old Host. That is not permission to kill it. The old Host may still own recoverable work or an external effect whose result is unknown. The user must be able to choose among retiring now, waiting, or canceling. After retirement, only one validated artifact may acquire the same State Root lease. + +If the compatibility epochs match but generations differ, ordinary compatible Surfaces may still share the Host. A local installation owner may request replacement because the selected Host implementation changed. Product version skew alone is not a rejection rule. + +## Current system + +### Runtime Host is already the process authority + +**Current.** The Host Kernel owns the State Root writer lease, active connections and operations, residencies, drain, close, and recovery. A Surface disconnect releases connection-scoped resources; it does not cancel admitted work. The stable architecture already distinguishes an ephemeral local Host from an operator-owned service Host and defines Host Generation separately from protocol compatibility. + +The protocol already carries optional Client `generation` and epoch-fenced takeover intent. The Kernel can report blocking activity and can retire an ephemeral Host through its single drain path. `host.upgrade.prepare` provides a compatible, authenticated pre-update retirement path. A generation mismatch discovered during connection provides the post-update startup path. + +### Desktop owns one local update adapter + +**Current.** Packaged Desktop uses `app.getVersion()` as its requested Host Generation. Its manager projects restart, wait, and cancel choices for a restartable local conflict. Before Electron installs a downloaded update, Desktop calls `host.upgrade.prepare`, waits for Host exit, and then delegates installation to Electron's updater. + +This is a Desktop adapter, not a machine-wide installation authority. It does not coordinate an independently installed CLI or an `npx` artifact. + +### CLI startup now has a bounded post-install reconciliation path + +**Current.** `runtime-host-installation-context.ts` resolves the running CLI package once and supplies its package root, display version, installation scope, and artifact generation. The resolver distinguishes release and development provenance internally: a released package uses `maka-agent@`, while each development process receives an explicit process-scoped generation. `cli-core.ts` and Runtime Host candidate selection consume the same immutable context. + +`connect-or-spawn.ts` now distinguishes two facts: + +- `generation` is an exact Host generation required by this connection; +- `candidateGeneration` is used only if this process wins the election and launches a new Host. + +An ordinary compatible CLI omits exact `generation`, so same-epoch build skew still connects without replacement. If no Host exists, its candidate publishes the CLI artifact generation. On an incompatible local ephemeral Host, TUI probes with the exact installed generation to obtain authoritative activity, then offers restart, wait, or cancel. Restart uses an exact observed Host Epoch takeover; wait drops the replacement request and re-observes after a bounded delay; cancel leaves the Host unchanged. + +This is only the post-install startup slice. `/exit` and `/quit` remain Surface disconnects, and `cli-core.ts` still has no `update` or `upgrade` command. + +### Remote setup already stages an exact package + +**Current.** `runtime-host-managed-deployment.ts` validates a self-contained release, copies it into a versioned managed directory, atomically renames the staging directory, and retains rollback behavior. Remote setup records exact Node and CLI paths for the service. Direct service installation rejects a CLI inside npm's temporary `_npx` cache. + +Remote Clients do not stop or silently upgrade that service. Its operator owns deployment policy. + +### Observed released-artifact evidence + +**Current, observed on 2026-08-19.** npm still maps `next` to `0.1.0-beta.1`, whose inspected artifact uses compatibility epoch 24. This worktree uses epoch 26. + +A real epoch-24 Host was started on an isolated State Root with zero Surface connections and one `scheduled-task` residency. The epoch-26 TUI reported the exact PID, epoch, generation, connection/operation counts, and residency. The three paths were then driven through a real pseudo-terminal: + +- **restart** retired only the observed epoch-24 Host and launched one epoch-26 successor with a new Host Epoch and the current CLI artifact generation; +- **cancel** exited the CLI and left the old Host and residency running; +- **wait** re-observed after two seconds and presented the decision again without stopping the Host or launching another writer. + +A same-epoch integration probe also confirmed that `candidateGeneration` does not reject a compatible existing Host generation, while a newly elected Host publishes the candidate generation. These probes establish the compatibility/generation and one-writer boundaries. They do not yet prove recovery of a real persisted Scheduled Task, storage downgrade, npm global package switching, or cross-platform replacement. + +## Proposed authority model + +**Planned.** Add one local installation-management plane outside the Runtime Host Domain. It coordinates artifacts; it does not own Runtime work. + +| Owner | Exclusive responsibility | Explicitly does not own | +|---|---|---| +| Package manager | Fetch or install a requested Maka release | Host drain, State Root recovery, or task safety | +| Local installation owner | Validate/stage Host artifacts, select the active local artifact, serialize cutover, and retain the previous artifact | Runtime state, Scheduled Tasks, external-effect settlement, or protocol compatibility | +| Runtime Host Kernel | Decide whether it can drain, stop admission, recover durable work, close, and release the writer lease | npm resolution, release download, or UI prompts | +| Surface adapter | Explain Host facts and collect the user's restart/wait/cancel decision | A second lifecycle state machine or direct process kill | +| Remote operator | Install and replace an explicitly configured service Host | Automatic updates initiated by a remote Surface | + +The local installation owner is machine/profile-local Client-side authority. Its minimal selection state belongs under Maka's Client data location, not inside a State Root, because one installation may serve multiple roots and the active Host must not mutate the selector for its own executable. Whether it becomes a class, a service, or a set of deep modules governed by one lock and record remains an implementation detail. + +### P1 convergence: one owner flow, not three Surface flows + +**Planned.** #3243 requires one installation-owner flow. It constrains artifact resolution, safe staging, and Host reconciliation to four stages inside one authority boundary: + +1. **Resolve** one immutable installation context: provenance, artifact identity, validated entrypoint, and display version. +2. **Stage** the artifact through one reusable validate/copy/atomic-publish transaction. +3. **Reconcile** the selected artifact with the observed Host and return a typed connect/restart/wait/cancel/operator-required outcome. +4. **Cut over** only after explicit consent: fence retirement by Host Epoch, atomically select, launch, and verify Ready. + +Only reconciliation and cutover make installation decisions. Resolution and staging are internal fact/transaction mechanisms, not additional authorities. Desktop, TUI, and CLI keep presentation adapters; the remote operator path may reuse staging but never the local auto-selection policy. P1 commits to this one owner flow and contract, not to a particular class or resident manager process. + +### One artifact identity + +**Current foundation.** CLI uses one `artifactGeneration` for candidate launch and explicit takeover. Released packages use their immutable npm name/version identity; development processes add a UUID so separate source processes do not pretend to be one artifact. The installation context retains only the package root, display version, artifact generation, and temporary `_npx` scope needed by current consumers; provenance participates in identity construction only inside the resolver. + +**Planned strengthening.** The identity must eventually distinguish a verified runnable payload, not merely a package version. A published npm package can add verified integrity; a bundled Desktop or managed artifact needs an equivalent build identity. + +Do not add parallel `releaseId`, `buildId`, `installedVersion`, and generation values that can disagree. Package version remains display and package-manager metadata. The installation record maps the artifact identity to an immutable validated entrypoint. + +The exact manifest encoding and integrity source remain **Exploratory**. The contract only requires stable equality, validation before selection, and enough metadata for a useful diagnostic. + +## One cutover protocol + +The planned local flow is: + +```mermaid +sequenceDiagram + participant Surface as Desktop / CLI / TUI + participant Install as Local installation owner + participant Old as Current Runtime Host + participant Store as State Root + participant New as Selected Host Artifact + + Surface->>Install: Reconcile desired artifact + Install->>Install: Lock, validate, and stage candidate + Install->>Old: Inspect epoch, generation, and blocking activity + Old-->>Surface: Project restart / wait / cancel facts + Surface-->>Install: Explicit user decision + Install->>Old: Prepare or epoch-fenced takeover + Old->>Store: Drain, close, release writer lease + Install->>Install: Atomically select staged artifact + Install->>New: Launch candidate for the same State Root + New->>Store: Acquire lease and recover durable state + New-->>Surface: Ready with new Host Epoch and artifact identity +``` + +Read the diagram from left to right as responsibility, not as a promise that every step is one protocol request. It intentionally omits package download and Domain-specific recovery details. Package download finishes before staging; recovery remains owned by the Host Composition. + +The invariants are: + +1. Validate and stage before asking the old Host to retire. +2. Fence retirement by the observed Host Epoch; a stale updater cannot retire a replacement. +3. Never run two writer Hosts for one State Root. +4. Do not select an artifact until it is complete and runnable. +5. Keep the previous artifact until the replacement is verified Ready. +6. Do not infer that an interrupted external effect is safe to replay. +7. Serialize local selection changes with one installation lock. + +### Pre-update and post-update entry paths + +**Mixed.** Both paths converge on the same Host retirement authority: + +- **Pre-update:** a compatible running Client stages the candidate, asks `host.upgrade.prepare`, and presents blocking activity before the package switch. +- **Post-update, Current for an incompatible persistent local CLI:** startup first performs ordinary compatibility admission, then requests the installed generation only to assess replacement. A mismatch returns observed Host facts. After explicit consent, an epoch-fenced takeover retires the old ephemeral Host and startup launches the current package candidate. + +These are not two update state machines. They are two discovery points around one cutover. Internally, the Kernel's prepare operation and handshake takeover should share retirement eligibility, drain, and outcome classification. + +**Current limitation.** Handshake takeover only becomes restartable when the old ephemeral Host has no accepted Client connections. The first delivery must not promise interruption of other connected Surfaces. They must disconnect, or a later protocol must explicitly define how the owner asks those Clients to leave. + +## Surface behavior + +### Desktop + +**Planned.** Keep Electron download/install state in the Desktop updater, but replace `app.getVersion()` as an isolated Host-selection rule with the shared artifact identity and installation record. Desktop remains responsible for native dialogs and restart presentation. + +Closing the window, quitting Maka, and installing an update remain different user actions: + +- closing a Surface may leave resident Host work running; +- quitting requests graceful Host retirement when policy allows it; +- updating stages the replacement first, retires the old Host, then launches the selected artifact. + +### Installed CLI and TUI + +**Current first slice.** Local CLI candidate launch carries the resolved artifact generation. An incompatible local ephemeral Host enters the TUI restart/wait/cancel adapter. A compatible Host remains usable even when its generation differs. Remote profiles never accept a local generation or takeover request. + +**Planned.** Extend reconciliation to explicit same-epoch owner actions, package switching, and every CLI entry point. This makes correctness independent of whether npm installation happened inside or outside Maka. + +Candidate TUI commands are adapters over the same coordinator: + +- `/exit`: disconnect only this Surface; +- `/host status`: show Host Epoch, artifact identity, compatibility, and bounded blocking activity; +- `/host stop` or `/host restart`: request a fenced graceful action and present restart/wait/cancel choices; +- `/update`: optionally invoke the package-manager adapter, then run the same reconciliation path. + +Command spelling is **Exploratory**. The ownership and `/exit` semantics are the planned contract. + +### What `maka update` may do + +**Exploratory.** npm should remain the release installation authority. Maka should not maintain a second release registry, semver resolver, or package database. A future `maka update` can be a thin package-manager adapter that: + +1. identifies the current installation provenance; +2. asks npm to install an explicit release or dist-tag through a supported command; +3. validates and stages the resulting Host artifact; +4. enters the same cutover protocol. + +If reliable self-replacement cannot be supported for a provenance or platform, the command should print the exact external npm command and let the next CLI startup reconcile. Startup reconciliation is required; a self-update command is optional. + +### `npx` + +**Current guard plus Exploratory ownership decision.** The CLI recognizes package roots under npm's temporary `_npx` cache. Such an invocation may identify the generation used for a new candidate, but it cannot turn Host facts into local replacement authority: TUI offers wait/cancel, never restart. + +#3244 must still select one public durability contract: + +1. invocation-owned: the Host cannot outlive the `npx` invocation; +2. managed artifact: the invocation copies the exact validated package into Maka-managed storage before starting durable Host work; +3. connect-only: `npx` may connect to an existing managed Host but cannot become its durable owner. + +If Scheduled Tasks or Goals are promised to outlive an `npx` Surface, the managed-artifact model is the coherent choice. The current guard prevents `npx` from replacing a persistent Host, but existing candidate launch can still create a Host from the cache; #3244 must remove that remaining ambiguous ownership. + +## Remote Host boundary + +**Current and retained.** A service Host is operator-owned. A Desktop or TUI connecting over an authenticated remote profile may report incompatibility, but it may not update, stop, or replace the service. #3203 was completed by #3246: the remote connector retains bounded handshake facts, emits the stable `RUNTIME_HOST_REMOTE_INCOMPATIBLE` diagnostic, and directs the operator to use compatible builds and restart the service after updating it. This remote error path remains separate from the local takeover adapter. + +Remote setup can reuse the same artifact validation/staging primitive and retirement result vocabulary. It must not share the local auto-selection policy. The operator chooses when to stage, drain, switch the exact service entrypoint, verify readiness, and roll back. + +## Failure and recovery contract + +| Failure point | Required outcome | +|---|---| +| Download or staging fails | Old selection and running Host remain unchanged | +| Candidate validation fails | Candidate is quarantined or removed; it cannot become selected | +| User cancels retirement | Old Host continues; staged artifact may remain reusable | +| User waits | Client removes exact replacement pressure, re-observes after a bounded delay, and continues automatically when the observed Host exits | +| Observed Host Epoch changes | Reject stale takeover and inspect the replacement again | +| Active connections block takeover | Name the bounded blocker; do not kill the Host | +| Old Host releases the lease but selection fails | Launch the previously selected retained artifact and report degraded recovery | +| New Host fails before Ready | Keep the failed artifact unselected, retry the previous artifact, and preserve the State Root | +| Durable task is recoverable | New Host recovery decides continuation; the installer does not replay it | +| External result is unknown | Preserve result-unknown state; never claim exactly-once execution | +| Concurrent updater starts | One installation lock elects the writer; the loser rereads the selected record | +| State storage cannot be opened by the candidate | Stop before mutation where possible; use #3227 storage preflight and do not claim downgrade safety without evidence | + +The installation record is not a universal update journal. It should contain only the selected artifact, retained fallback, artifact metadata, and atomic transition facts needed to recover an interrupted cutover. Runtime and task state stays in the State Root. + +## Delivery order + +1. **Current P1a:** installation context, candidate generation, incompatible-startup activity assessment, and epoch-fenced TUI restart/wait/cancel for persistent local CLI packages. +2. **Planned P1b:** converge reusable artifact staging, atomic package selection, same-epoch explicit owner actions, replacement verification, and failure recovery into one installation-owner flow. +3. **Planned:** adapt Desktop and explicit TUI commands to the same owner contract without moving Host authority into either Surface. +4. **Exploratory:** add a thin npm update helper where provenance and self-replacement are reliable. +5. **Exploratory:** implement the chosen `npx` ownership contract. +6. **Planned for remote, separately operated:** reuse staging primitives without enabling remote Client auto-update. + +## Verification matrix + +Tests must use released or packed artifacts, not only two source checkouts that happen to share dependencies. + +| Old owner | New Surface | Compatibility | Residency/connections | Expected result | +|---|---|---|---|---| +| installed CLI N | installed CLI N+1 | same epoch | idle | explicit replacement succeeds; durable state recovers | +| installed CLI N | TUI N+1 | same epoch | Scheduled Task residency | restart/wait/cancel; wait does not keep Host alive | +| Desktop N | installed CLI N+1 | same epoch | Desktop still connected | compatible Surface may connect; owner replacement is blocked, not forced | +| CLI epoch N | CLI epoch N+1 | different epoch | idle | explicit fenced handoff, then new Host Ready | +| CLI epoch N | CLI epoch N+1 | different epoch | active external work | no automatic kill; interruption warning preserves result-unknown semantics | +| installed CLI | external `npm install` then startup | either | durable residency | startup reconciliation works without prior `maka update` | +| `npx` | later installed CLI | either | Host outlives invocation | behavior matches the selected #3244 contract | +| remote service N | Desktop N+1 | either | any | no Client-side update; exact operator guidance | +| selected artifact N | failed artifact N+1 | either | old Host retired | retained N restarts without mutating durable state | + +Include Windows process replacement, npm global installation, npm cache cleanup, macOS packaged Desktop, Linux local IPC, and remote service fixtures. Record the release version, compatibility epoch, artifact identity, State Root identity, and observed Host Epoch in every failure diagnostic. + +## Open decisions + +1. Which #3244 ownership model is the product promise for `npx`? +2. Is `maka update` supported on every npm installation provenance, or is external npm plus startup reconciliation the baseline? +3. What verified digest or build identity forms the opaque generation for npm, Desktop bundles, and development builds? +4. May an installation owner ever ask other connected local Surfaces to disconnect, or must replacement always wait for zero connections? +5. What storage compatibility and preflight contract from #3227 is required before selecting a candidate, and is downgrade ever supported? +6. How long must the previous artifact be retained, and what disk-pressure policy may remove it? + +## Source anchors + +- Stable authority and Host identity: `docs/architecture/runtime-host-architecture.md` +- Remote operator workflow: `docs/runtime-host-remote-access.md` +- CLI candidate selection: `packages/cli/src/runtime-host-cli-context.ts` +- TUI conflict projection: `packages/cli/src/runtime-host-tui-command.ts` +- CLI command registration: `packages/cli/src/cli-core.ts` +- Desktop generation and updater: `apps/desktop/src/main/runtime-host-boot.ts`, `apps/desktop/src/main/runtime-host-desktop-manager.ts`, `apps/desktop/src/main/app-update-service.ts` +- Host retirement authority: `packages/runtime-host/src/server/host-kernel.ts` +- Generation and takeover handshake: `packages/runtime-host/src/client/connection.ts`, `packages/runtime-host/src/client/connect-or-spawn.ts`, `packages/runtime-host/src/protocol/index.ts` +- Existing exact-package staging: `packages/cli/src/runtime-host-managed-deployment.ts` +- Persistent-service and `_npx` guard: `packages/cli/src/runtime-host-service-manager.ts` + +## Glossary + +| Term | Meaning in this draft | +|---|---| +| Surface | Desktop, TUI, CLI, or another Client presentation | +| Local installation owner | The one Client-side coordinator selecting a durable local Host artifact | +| Artifact identity | Opaque, validated identity used as Host Generation | +| Reconciliation | Comparing selected artifact, observed Host, and compatibility before connecting or replacing | +| Retirement | Host-owned drain and close ending with writer-lease release | +| Cutover | Staging, explicit retirement decision, atomic selection, launch, recovery, and readiness verification | diff --git a/docs/architecture/runtime-host-installation-lifecycle-draft.zh-CN.md b/docs/architecture/runtime-host-installation-lifecycle-draft.zh-CN.md new file mode 100644 index 0000000000..8bdd431aca --- /dev/null +++ b/docs/architecture/runtime-host-installation-lifecycle-draft.zh-CN.md @@ -0,0 +1,305 @@ +--- +doc_id: architecture.runtime-host-installation-lifecycle +title: "Runtime Host 安装与更新生命周期" +language: zh-CN +source_language: zh-CN +counterpart: ./runtime-host-installation-lifecycle-draft.md +implementation_status: mixed +document_status: draft +translation_status: synced +last_verified: 2026-08-19 +owners: + - maka-backend +--- + +# Runtime Host 安装与更新生命周期 + +> 核心问题:当 Desktop、已安装的 CLI 或一次 `npx` 调用各自更新,而本地 Runtime Host 仍持有持久工作时,谁选择 Host 产物、谁让旧进程退出,以及 Maka 如何同时避免工作丢失和“升级后无法启动”? + +本文是稳定版 [Runtime Host 架构](./runtime-host-architecture.zh-CN.md)的扩展草案,为 #3231 及其 CLI、`npx`、TUI 子 Issue 定义 ownership model。它不会把 package manager 变成 Runtime authority,也不会允许 remote Client 更新 service Host。 + +## 状态用语 + +- **Current(当前)**:2026-08-19 时,引用源码已经实现的行为。 +- **Planned(计划)**:Tracking Issue 所需的共享 contract 提案。 +- **Exploratory(探索)**:本文无法单独确定、仍需产品或兼容性决策的内容。 + +## 用一次具体升级说明问题 + +假设 epoch 24 的 CLI 启动了本地 Host,一项持久 Scheduled Task 让 Host 保持 resident。之后用户安装 epoch 25 的 CLI 并启动它。 + +新旧进程涉及四个不同问题: + +| 值 | 它回答什么 | 不能把它理解成什么 | +|---|---|---| +| `compatibilityEpoch` | Client 与 Host 能否安全使用同一套 Domain contract? | 下一次应该由哪个已安装包拥有 Host 进程 | +| Host Epoch | 当前是哪个进程持有 State Root writer lease? | 产品版本或 package version | +| Host Generation | 哪一个确切的 local-owner artifact 请求了这个 Host 进程? | 所有已连接 Surface 的通用兼容规则 | +| Package release | npm 或 Desktop 分发的是哪个版本? | protocol 保证或存活进程 identity | + +如果 compatibility epoch 不同,新 Client 不能操作旧 Host,但这不等于它有权杀掉旧 Host。旧 Host 可能仍拥有可恢复工作,也可能有结果未知的外部 effect。用户必须可以选择立即退出、等待或取消。旧 Host 退出后,只能有一个经过验证的 artifact 获得同一 State Root lease。 + +如果 compatibility epoch 相同但 generation 不同,普通兼容 Surface 仍可以共用 Host。local installation owner 可以因为所选 Host implementation 已改变而请求 replacement,但不能仅凭 product version 不同就拒绝连接。 + +## 当前系统 + +### Runtime Host 已经是进程 authority + +**Current。** Host Kernel 拥有 State Root writer lease、active connections 与 operations、residencies、drain、close 和 recovery。Surface disconnect 只释放 connection-scoped resources,不会取消已经 admit 的工作。稳定架构已经区分 ephemeral local Host 与 operator-owned service Host,也已把 Host Generation 和 protocol compatibility 分开。 + +Protocol 已支持可选的 Client `generation` 与由 Host Epoch fencing 的 takeover intent。Kernel 可以报告 blocking activity,并通过唯一 drain path 退出 ephemeral Host。`host.upgrade.prepare` 是“更新前、仍兼容”的 authenticated retirement path;连接时发现 generation mismatch 则构成“外部已经更新后再启动”的入口。 + +### Desktop 已拥有一个本地更新 adapter + +**Current。** Packaged Desktop 使用 `app.getVersion()` 作为请求的 Host Generation。其 manager 会在 restartable local conflict 时投影 restart、wait、cancel 选项。Electron 安装已下载更新前,Desktop 调用 `host.upgrade.prepare`,等待 Host 退出,再把安装交给 Electron updater。 + +这只是 Desktop adapter,不是整台机器的 installation authority。它无法协调独立安装的 CLI 或 `npx` artifact。 + +### CLI startup 已有一条有界的“安装后 reconciliation”路径 + +**Current。** `runtime-host-installation-context.ts` 只解析一次当前 CLI package,提供 package root、展示版本、installation scope 与 artifact generation。Resolver 在内部区分 release/development provenance:发布 package 使用 `maka-agent@`;每个 development process 使用明确的 process-scoped generation。`cli-core.ts` 与 Runtime Host candidate selection 共用这一个 immutable context。 + +`connect-or-spawn.ts` 现在区分两个事实: + +- `generation`:本次连接要求的精确 Host generation; +- `candidateGeneration`:仅当本进程赢得 election 并新建 Host 时使用。 + +普通兼容 CLI 不发送精确 `generation`,所以 same-epoch build skew 仍可连接,不会强制 replacement。没有 Host 时,新 candidate 会发布 CLI artifact generation。遇到不兼容的本地 ephemeral Host 时,TUI 再用 installed generation 探测权威 activity,并提供 restart、wait、cancel。Restart 使用观察到的精确 Host Epoch takeover;wait 会撤掉 replacement request,在有界延时后重新观察;cancel 保持 Host 不变。 + +这只是“外部已经安装新 CLI 后再启动”的切片。`/exit` 与 `/quit` 仍然只断开 Surface,`cli-core.ts` 仍没有 `update` 或 `upgrade` command。 + +### Remote setup 已经会暂存确切 package + +**Current。** `runtime-host-managed-deployment.ts` 会验证 self-contained release,把它复制进带版本的 managed directory,通过 atomic rename 发布 staging directory,并保留 rollback 行为。Remote setup 会为 service 记录确切的 Node 与 CLI path。直接安装 service 时会拒绝 npm 临时 `_npx` cache 中的 CLI。 + +Remote Client 不会停止或偷偷更新该 service;deployment policy 属于 remote operator。 + +### 已观察的 released-artifact 证据 + +**Current,观察日期 2026-08-19。** npm `next` 仍指向 `0.1.0-beta.1`,检查到的该 artifact 使用 compatibility epoch 24;当前 worktree 使用 epoch 26。 + +在隔离 State Root 上启动真实 epoch-24 Host,保持零个 Surface connection,并持有一个 `scheduled-task` residency。epoch-26 TUI 展示了精确 PID、epoch、generation、connection/operation count 与 residency。随后通过真实 pseudo-terminal 驱动三条路径: + +- **restart:** 只让观察到的 epoch-24 Host 退出;epoch-26 successor 使用新的 Host Epoch 与当前 CLI artifact generation 成为唯一 writer; +- **cancel:** CLI 退出,旧 Host 与 residency 保持运行; +- **wait:** 两秒后重新观察并再次显示选择,不停止旧 Host,也不启动第二个 writer。 + +Same-epoch integration probe 也证明了 `candidateGeneration` 不会拒绝 generation 不同但兼容的 existing Host;新选出的 Host 会发布 candidate generation。这些实验验证了 compatibility/generation 与单 writer 边界,但尚未证明真实持久化 Scheduled Task 的恢复、storage downgrade、npm global package switch 或跨平台 replacement。 + +## 提议的 authority model + +**Planned。** 在 Runtime Host Domain 外增加唯一的 local installation-management plane。它协调 artifact,不拥有 Runtime work。 + +| Owner | 唯一职责 | 明确不负责 | +|---|---|---| +| Package manager | 获取或安装用户指定的 Maka release | Host drain、State Root recovery、task safety | +| Local installation owner | 验证/暂存 Host artifact、选择 active local artifact、串行化 cutover、保留上一个 artifact | Runtime state、Scheduled Tasks、external-effect settlement、protocol compatibility | +| Runtime Host Kernel | 决定能否 drain,停止 admission,恢复持久工作,close 并释放 writer lease | npm resolution、release download、UI prompt | +| Surface adapter | 解释 Host facts,收集用户的 restart/wait/cancel 决定 | 第二套 lifecycle state machine 或直接 kill process | +| Remote operator | 安装和替换明确配置的 service Host | 由 remote Surface 发起自动更新 | + +Local installation owner 是 machine/profile-local 的 Client-side authority,应把最小 selection state 放在 Maka 的 Client data location,而不是 State Root 内:同一 installation 可能服务多个 root,active Host 也不应修改指向自己 executable 的 selector。它最终是一个 class、service 还是一组受同一 lock/record 约束的深模块,仍是 implementation detail。 + +### P1 收敛:一条 owner flow,而不是三个 Surface flow + +**Planned。** #3243 已要求一条 installation-owner flow。它把 artifact resolution、safe staging 和 Host reconciliation 约束为同一个 authority boundary 的四个阶段: + +1. **Resolve:** 一次生成 immutable installation context,包括 provenance、artifact identity、validated entrypoint 与 display version。 +2. **Stage:** 通过唯一、可复用的 validate/copy/atomic-publish transaction 暂存 artifact。 +3. **Reconcile:** 对比 selected artifact 与 observed Host,返回 typed connect/restart/wait/cancel/operator-required outcome。 +4. **Cut over:** 只在用户明确同意后执行:用 Host Epoch fence retirement,atomic select、launch,并验证 Ready。 + +只有 reconciliation 与 cutover 作 installation decision。Resolution 与 staging 只是内部事实/事务机制,不是额外 authority。Desktop、TUI、CLI 保留 presentation adapter;remote operator path 可以复用 staging,但不能复用 local auto-selection policy。P1 承诺的是这一条 owner flow 与 contract,不预先承诺一个特定 class 或常驻 manager process。 + +### 一个 artifact identity + +**Current foundation。** CLI 使用一个 `artifactGeneration` 完成 candidate launch 与明确 takeover。发布 package 使用 immutable npm name/version identity;development process 加 UUID,避免两个 source process 冒充同一 artifact。Installation context 只保留现有消费者需要的 package root、展示版本、artifact generation,以及 package 是否位于临时 `_npx` cache;provenance 只在 resolver 内参与 identity 生成。 + +**Planned strengthening。** Identity 最终必须区分经过验证的 runnable payload,而不能只依赖 package version。发布 npm package 可以增加 verified integrity;bundled Desktop 与 managed artifact 需要等价 build identity。 + +不要同时增加可能互相矛盾的 `releaseId`、`buildId`、`installedVersion` 和 generation。Package version 继续作为展示和 package-manager metadata;installation record 把 artifact identity 映射到 immutable、已验证的 entrypoint。 + +具体 manifest 编码和 integrity 来源仍是 **Exploratory**。Contract 只要求 identity 能稳定比较、选择前完成验证,并包含足够的诊断 metadata。 + +## 一套 cutover protocol + +计划中的本地流程如下: + +```mermaid +sequenceDiagram + participant Surface as Desktop / CLI / TUI + participant Install as Local installation owner + participant Old as Current Runtime Host + participant Store as State Root + participant New as Selected Host Artifact + + Surface->>Install: Reconcile desired artifact + Install->>Install: 加锁、验证并暂存 candidate + Install->>Old: 检查 epoch、generation 与 blocking activity + Old-->>Surface: 投影 restart / wait / cancel facts + Surface-->>Install: 用户明确选择 + Install->>Old: Prepare 或 epoch-fenced takeover + Old->>Store: Drain、close、释放 writer lease + Install->>Install: 原子选择 staged artifact + Install->>New: 为同一 State Root 启动 candidate + New->>Store: 获取 lease 并恢复 durable state + New-->>Surface: 以新 Host Epoch 与 artifact identity Ready +``` + +图中从左到右表示 responsibility,而不是承诺每一步都对应一次 protocol request。图中故意省略 package download 与 Domain-specific recovery:下载必须在 staging 前完成,恢复仍由 Host Composition 拥有。 + +必须保持以下 invariant: + +1. 请求旧 Host 退出前,先验证并暂存新 artifact。 +2. 使用观察到的 Host Epoch fence retirement;过期 updater 不能让 replacement 退出。 +3. 一个 State Root 永远不能同时运行两个 writer Host。 +4. Artifact 未完整、不可运行时,不能把它设为 selected。 +5. Replacement 验证 Ready 前保留 previous artifact。 +6. 不得推断被中断的 external effect 可以安全 replay。 +7. 使用唯一 installation lock 串行化 local selection change。 + +### 更新前与更新后的两个入口 + +**Mixed。** 两个入口最终汇入同一个 Host retirement authority: + +- **更新前:** 仍兼容的 Client 先 stage candidate,调用 `host.upgrade.prepare`,并在 package switch 前展示 blocking activity。 +- **更新后(persistent local CLI 遇到不兼容 Host 时已是 Current):** startup 先执行普通 compatibility admission,随后只为评估 replacement 请求 installed generation。Mismatch 返回观察到的 Host facts;用户明确同意后,通过 epoch-fenced takeover 让旧 ephemeral Host 退出,再启动当前 package candidate。 + +这不是两套 update state machine,而是同一次 cutover 前后的两个 discovery point。Kernel 内部的 prepare operation 与 handshake takeover 应共享 retirement eligibility、drain 和 outcome classification。 + +**当前限制。** Handshake takeover 只有在旧 ephemeral Host 没有 accepted Client connection 时才 restartable。第一期不能承诺强行中断其他已连接 Surface;它们必须先断开,或者由未来 protocol 明确定义 owner 如何请求这些 Client 离开。 + +## Surface 行为 + +### Desktop + +**Planned。** Electron download/install state 继续属于 Desktop updater,但不再把 `app.getVersion()` 当作孤立的 Host-selection rule,而应使用共享 artifact identity 与 installation record。Desktop 仍负责 native dialog 与 restart presentation。 + +关闭 window、退出 Maka、安装 update 是三个不同 user action: + +- 关闭一个 Surface 可以让有 resident work 的 Host 继续运行; +- 退出 Maka 会在 policy 允许时请求 graceful Host retirement; +- 更新先 stage replacement,再退出旧 Host,最后启动 selected artifact。 + +### 已安装 CLI 与 TUI + +**Current first slice。** Local CLI candidate launch 会携带解析后的 artifact generation。不兼容的本地 ephemeral Host 会进入 TUI restart/wait/cancel adapter。兼容 Host 即使 generation 不同仍可继续使用。Remote profile 永远不接受 local generation 或 takeover request。 + +**Planned。** 把 reconciliation 扩展到 same-epoch 显式 owner action、package switch 与所有 CLI entry point,使 correctness 不依赖 npm installation 是在 Maka 内还是外部完成。 + +候选 TUI commands 只是共享 coordinator 的 adapters: + +- `/exit`:只断开当前 Surface; +- `/host status`:显示 Host Epoch、artifact identity、compatibility 与有界 blocking activity; +- `/host stop` 或 `/host restart`:请求 fenced graceful action,并展示 restart/wait/cancel; +- `/update`:可选地调用 package-manager adapter,然后进入同一 reconciliation path。 + +Command 命名仍是 **Exploratory**;ownership 与 `/exit` 语义属于 planned contract。 + +### `maka update` 可以做什么 + +**Exploratory。** npm 应继续拥有 release installation authority。Maka 不应维护第二份 release registry、semver resolver 或 package database。未来的 `maka update` 可以只是一个薄 package-manager adapter: + +1. 识别当前 installation provenance; +2. 通过受支持的 npm command 安装明确 release 或 dist-tag; +3. 验证并 stage 得到的 Host artifact; +4. 进入同一 cutover protocol。 + +如果某种 provenance 或 platform 无法可靠 self-replace,该 command 应输出确切的外部 npm command,并让下一次 CLI startup 做 reconciliation。Startup reconciliation 是必需能力;self-update command 是可选能力。 + +### `npx` + +**Current guard + Exploratory ownership decision。** CLI 会识别位于 npm 临时 `_npx` cache 下的 package root。这种 invocation 可以标识新 candidate 使用的 generation,但不能把 Host facts 变成本地 replacement authority:TUI 只提供 wait/cancel,不提供 restart。 + +#3244 仍必须选择一个公开 durability contract: + +1. invocation-owned:Host 不能比这次 `npx` invocation 活得更久; +2. managed artifact:开始 durable Host work 前,把确切、已验证 package 复制到 Maka-managed storage; +3. connect-only:`npx` 只能连接已有 managed Host,不能成为 durable owner。 + +如果产品承诺 Scheduled Tasks 或 Goals 在 `npx` Surface 退出后继续运行,managed-artifact model 是一致的选择。当前 guard 已阻止 `npx` 替换 persistent Host,但现有 candidate launch 仍可能从 cache 创建 Host;#3244 必须消除这最后一处 ownership 模糊。 + +## Remote Host 边界 + +**Current,并继续保持。** Service Host 由 operator 拥有。通过 authenticated remote profile 连接的 Desktop 或 TUI 可以报告 incompatibility,但不能 update、stop 或 replace 该 service。#3203 已由 #3246 完成:remote connector 会保留经过收敛的 handshake facts,输出稳定的 `RUNTIME_HOST_REMOTE_INCOMPATIBLE` 诊断,并引导 operator 使用兼容 build、更新后重启 service。这个 remote error path 与 local takeover adapter 保持分离。 + +Remote setup 可以复用相同 artifact validation/staging primitive 与 retirement result vocabulary,但不能共享 local auto-selection policy。Operator 自己决定何时 stage、drain、切换确切 service entrypoint、验证 readiness 和 rollback。 + +## 失败与恢复 contract + +| 失败点 | 必须得到的结果 | +|---|---| +| Download 或 staging 失败 | 旧 selection 和运行中的 Host 不变 | +| Candidate validation 失败 | Candidate 被隔离或删除,不能设为 selected | +| 用户取消 retirement | 旧 Host 继续运行;staged artifact 可以保留供复用 | +| 用户选择等待 | Client 撤掉精确 replacement pressure,在有界延时后重新观察,并在观察到的 Host 退出后自动继续 | +| 观察到的 Host Epoch 改变 | 拒绝 stale takeover,重新检查 replacement | +| Active connections 阻止 takeover | 显示有界 blocker,不能 kill Host | +| 旧 Host 已释放 lease,但 selection 失败 | 启动保留的 previous selected artifact,并报告 degraded recovery | +| 新 Host 在 Ready 前失败 | 保持 failed artifact 为 unselected,重试 previous artifact,保留 State Root | +| Durable task 可恢复 | 由新 Host recovery 决定 continuation;installer 不 replay task | +| External result unknown | 保留 result-unknown state;不能声称 exactly-once execution | +| 并发 updater 启动 | 一把 installation lock 选出 writer;失败方重读 selected record | +| Candidate 无法打开 State storage | 尽可能在 mutation 前停止;使用 #3227 storage preflight,没有证据时不能声称支持 downgrade | + +Installation record 不是通用 update journal。它只应保存 selected artifact、retained fallback、artifact metadata,以及恢复中断 cutover 所需的原子 transition facts。Runtime 与 task state 仍在 State Root。 + +## 交付顺序 + +1. **Current P1a:** installation context、candidate generation、不兼容 startup 的 activity assessment,以及 persistent local CLI 的 epoch-fenced TUI restart/wait/cancel。 +2. **Planned P1b:** 把 reusable artifact staging、atomic package selection、same-epoch 显式 owner action、replacement verification 与 failure recovery 收敛到一条 installation-owner flow。 +3. **Planned:** 把 Desktop 与显式 TUI command 接到同一个 owner contract,不把 Host authority 移进任一 Surface。 +4. **Exploratory:** 在 provenance 与 self-replacement 可靠的平台增加薄 npm update helper。 +5. **Exploratory:** 实现选定的 `npx` ownership contract。 +6. **Remote 单独运营的 Planned:** 复用 staging primitives,但不允许 remote Client auto-update。 + +## 验证矩阵 + +测试必须使用已发布或 `npm pack` 得到的 artifact,不能只用两份恰好共享 dependencies 的 source checkout。 + +| 旧 owner | 新 Surface | Compatibility | Residency/connections | 预期结果 | +|---|---|---|---|---| +| installed CLI N | installed CLI N+1 | same epoch | idle | 明确 replacement 成功;durable state 恢复 | +| installed CLI N | TUI N+1 | same epoch | Scheduled Task residency | restart/wait/cancel;wait 不让 Host 因 Client 而继续存活 | +| Desktop N | installed CLI N+1 | same epoch | Desktop 仍连接 | 兼容 Surface 可以连接;owner replacement 被阻止而非强制执行 | +| CLI epoch N | CLI epoch N+1 | different epoch | idle | 显式 fenced handoff 后,新 Host Ready | +| CLI epoch N | CLI epoch N+1 | different epoch | active external work | 不自动 kill;interruption warning 保留 result-unknown 语义 | +| installed CLI | 外部 `npm install` 后 startup | 任意 | durable residency | 即使未先运行 `maka update`,startup reconciliation 也能工作 | +| `npx` | 后续 installed CLI | 任意 | Host 超过 invocation 生命周期 | 行为符合选定的 #3244 contract | +| remote service N | Desktop N+1 | 任意 | 任意 | Client 不更新;显示确切 operator guidance | +| selected artifact N | 失败的 artifact N+1 | 任意 | 旧 Host 已退出 | retained N 重启,且不修改 durable state | + +覆盖 Windows process replacement、npm global installation、npm cache cleanup、macOS packaged Desktop、Linux local IPC 与 remote service fixtures。每条失败诊断必须记录 release version、compatibility epoch、artifact identity、State Root identity 与观察到的 Host Epoch。 + +## 尚未决定的问题 + +1. `npx` 的产品承诺选择 #3244 中哪种 ownership model? +2. 所有 npm installation provenance 都支持 `maka update`,还是把外部 npm 加 startup reconciliation 作为 baseline? +3. npm、Desktop bundle、development build 分别使用什么 verified digest/build identity 构成 opaque generation? +4. Installation owner 是否可以请求其他已连接 local Surface 断开,还是 replacement 必须永远等待 connection 数为零? +5. 选择 candidate 前需要 #3227 提供怎样的 storage compatibility/preflight contract,是否支持 downgrade? +6. Previous artifact 至少保留多久,disk-pressure policy 可以在何时删除它? + +## 源码锚点 + +- 稳定 authority 与 Host identity:`docs/architecture/runtime-host-architecture.zh-CN.md` +- Remote operator workflow:`docs/runtime-host-remote-access.md` +- CLI candidate selection:`packages/cli/src/runtime-host-cli-context.ts` +- TUI conflict projection:`packages/cli/src/runtime-host-tui-command.ts` +- CLI command registration:`packages/cli/src/cli-core.ts` +- Desktop generation 与 updater:`apps/desktop/src/main/runtime-host-boot.ts`、`apps/desktop/src/main/runtime-host-desktop-manager.ts`、`apps/desktop/src/main/app-update-service.ts` +- Host retirement authority:`packages/runtime-host/src/server/host-kernel.ts` +- Generation 与 takeover handshake:`packages/runtime-host/src/client/connection.ts`、`packages/runtime-host/src/client/connect-or-spawn.ts`、`packages/runtime-host/src/protocol/index.ts` +- 已有 exact-package staging:`packages/cli/src/runtime-host-managed-deployment.ts` +- Persistent-service 与 `_npx` guard:`packages/cli/src/runtime-host-service-manager.ts` + +## 术语表 + +| 术语 | 本文含义 | +|---|---| +| Surface | Desktop、TUI、CLI 或其他 Client presentation | +| Local installation owner | 选择 durable local Host artifact 的唯一 Client-side coordinator | +| Artifact identity | 作为 Host Generation 使用的 opaque、validated identity | +| Reconciliation | 连接或替换前,对比 selected artifact、observed Host 与 compatibility | +| Retirement | Host-owned drain 与 close,最终释放 writer lease | +| Cutover | Staging、明确 retirement decision、atomic selection、launch、recovery 与 readiness verification | diff --git a/docs/architecture/runtime-host-installation-lifecycle-simplification-audit.zh-CN.md b/docs/architecture/runtime-host-installation-lifecycle-simplification-audit.zh-CN.md new file mode 100644 index 0000000000..7fdb3f0dbf --- /dev/null +++ b/docs/architecture/runtime-host-installation-lifecycle-simplification-audit.zh-CN.md @@ -0,0 +1,144 @@ +--- +doc_id: architecture.runtime-host-installation-lifecycle-simplification-audit +title: "Runtime Host 安装与更新生命周期简化审计" +language: zh-CN +source_language: zh-CN +implementation_status: mixed +document_status: draft +translation_status: source-only +last_verified: 2026-08-19 +owners: + - maka-backend +--- + +# Runtime Host 安装与更新生命周期简化审计 + +> 核心问题:当前候选实现为 CLI/TUI 增加 Host generation reconciliation 后,哪些概念可以在进入 PR review 前直接消失,哪些更大的统一仍缺少产品或 authority 决策? + +本文只审计 `design/3231-runtime-host-update-lifecycle` 相对 `origin/main` 的候选实现及其直接相邻边界,不实施生产代码改动,也不把未来设计写成 implementation plan。审计读者是准备 review #3231、#3243、#3244、#3245 的维护者。 + +## 结论 + +没有 P0。一个 P1 已由 #3231/#3243 的明确 ownership 决策闭合:Desktop、installed CLI 与 TUI 的 local artifact selection、staging、replacement retry 和 verification 应收敛为一条 installation-owner flow,而不是让每个 Surface 各自拥有 lifecycle policy。这个 P1 承诺的是 authority 与 contract,不要求预先创建一个特定 class 或常驻 manager process。 + +当前 P1a 切片没有引入第二个 Runtime authority:Host Kernel 仍独占 State Root writer lease、activity、drain 和 retirement;TUI 只投影事实并收集用户决定;remote 仍由 operator 管理。 + +审计还发现两个可以在 PR 前单独处理的小型删除项,现已在 audit 结束后的本地 follow-up 中完成: + +- P2:让 `installationScope` 成为 installation context 中 replacement authority 的唯一 representation,删除可推导的 `canReplaceLocalHost` 和未被消费者使用的公开 `provenance` 字段。 +- P3:删除已经只被测试引用的 `shouldRetryRuntimeHostConflict()`,把 wait 解析保留在唯一的 restart/wait/cancel decision parser 中。 + +仍未决定的是 installation-owner flow 的具体 module shape,以及 `npx` 是否进入该 owner。把逻辑 authority 固化成名为 `Local Installation Manager` 的单一 class/process 不属于 P1 的必要条件。 + +## Coverage + +| Architecture slice | Status | Demand chain 与证据 | 结论 | +|---|---|---|---| +| CLI package identity | Reviewed | `cli-core.ts` 消费 package root/version;`runtime-host-cli-context.ts` 消费 artifact generation 和 replacement eligibility;context tests 覆盖 release/development/`npx` | 存在两个多余公开字段,形成 P2 | +| Local connect/election | Reviewed | `connect-or-spawn.ts`、connection handshake、Host Kernel 与 candidate-generation tests | `generation` 与 `candidateGeneration` 语义不同,均有生产需求 | +| TUI conflict adapter | Reviewed | `runtime-host-tui-command.ts`、`runtime-host-tui-context.ts`、真实 PTY restart/wait/cancel probe | 旧 wait helper 已无生产消费者,形成 P3 | +| Desktop update/restart | Reviewed | `runtime-host-boot.ts`、`runtime-host-desktop-manager.ts`、`app-update-service.ts`、upgrade dialog tests | presentation 和 Electron updater 保留;artifact selection/retry 加入 P1 owner flow | +| npm/`npx` provenance | Reviewed | shared `_npx` detector、service installation guard、installation-context tests | detector 共享合理;durability contract 仍是 Decision gate | +| Managed remote service | Reviewed as adjacent boundary | `runtime-host-managed-deployment.ts`、service manager、`connectRemoteRuntimeHostProfile()` | staging 可作为未来复用证据,但 remote operator authority 不在本次删除范围 | +| 文档与测试 representation | Reviewed | 双语 design draft、CLI/Host tests、runtime probes | 删除字段和 helper 时对应断言与 Current 文案也可消失 | +| Storage migration/downgrade | Partial, out of candidate scope | 只核实 Host startup 与 #3227 的边界,未验证全部 released artifact | 不据此提出 rollback/downgrade simplification | + +## P0 + +无。 + +## P1:把 local artifact lifecycle 收敛成一条 installation-owner flow + +### 删除证明 + +| 问题 | 证据 | +|---|---| +| 当前 authority 与 consumers | Desktop 用 app version 请求 generation 并维护 restart/wait/cancel loop;CLI 从当前 package 启动 candidate;TUI 投影 conflict;managed deployment 已有 validate/stage/atomic-publish transaction | +| 当前需求链 | #3231 把 package switching 放在 installation-management plane;#3243 明确要求 one installation-owner flow,并要求并发更新收敛到一个 installation transition | +| 最强保留理由 | Desktop bundle、global CLI、TUI 和 `npx` 的 UI、签名、权限与进程生命周期不同,分别实现可以减少初期共享 contract | +| 为什么仍可删除 | Surface 差异只保留 presentation/package adapter;artifact identity、selection、fencing、staging、replacement verification 和并发 transition 是同一 authority。分别保留会在同一 State Root 上形成互相竞争的 owner policy | +| 消失内容 | Desktop-only Host-selection policy、CLI implicit current-package ownership、未来 TUI 自有 replacement loop、各入口重复的 package path/identity inference,以及各自的 transition state | +| 新增或移动内容 | 一条 machine/profile-local installation-owner contract;内部可以由受同一 lock/record 约束的深模块组成,不要求单一 class/process | +| 为什么是净减少 | 一个 selection fact、一把 transition lock 和一套 typed outcomes 取代多个需要彼此同步的 owner state;Runtime state、UI state 和 remote operator state 都不移入其中 | +| 放弃的能力 | Surface 不能仅凭自己的 product version 直接抢占 Host,也不能从临时/未验证 artifact 启动 durable replacement | +| 影响半径与不确定性 | Local Desktop/CLI/TUI startup 与 update;artifact integrity、跨平台 atomic switch 和 `npx` ownership 仍需独立证据,但不改变唯一 owner 的已确认边界 | + +P1a 只建立 immutable CLI installation context、candidate generation 与 cross-epoch startup reconciliation。它是 owner flow 的一个 vertical slice,不代表 staging、selection record、same-epoch action 或 replacement verification 已完成。 + +## P2:删除 installation context 的重复 representation + +### 删除证明 + +| 问题 | 证据 | +|---|---| +| 当前 authority | npm/package manifest 决定 package identity;`isTemporaryNpxInstallation()` 判断 package root 是否位于临时 `_npx` cache;Runtime Host handshake 决定能否 takeover | +| 当前 production consumers | `packageRoot` 和 `version` 供 setup/CLI 使用;`artifactGeneration` 供 candidate launch 和 exact takeover 使用;replacement eligibility 供 TUI 决定是否展示 restart | +| 当前重复 | context 同时返回 `installationScope` 与由它直接计算的 `canReplaceLocalHost`;`provenance` 只用于 resolver 内生成 artifact identity,返回后没有 production consumer | +| 最强保留理由 | 未来 Local Installation Manager 可能需要展示 provenance,调用方读取布尔值也较方便 | +| 为什么仍可删除 | 未来需求不能证明当前公开 contract;调用方可以从唯一的 `installationScope` 推导 eligibility,resolver 内仍可用局部 provenance 计算 generation | +| 消失内容 | 两个 context interface 字段、重复 fixture/assertion,以及文档中暗示它们都是当前公共事实的表述 | +| 新增或移动内容 | 不增加新 authority;只在现有 CLI connection seam 从 `installationScope` 推导一次 eligibility | +| 放弃的能力 | 当前无;未来需要 provenance 时必须用有实际消费者的 typed contract 重新引入 | +| 影响半径与不确定性 | 仅候选分支的 CLI context、fixtures 和文档;未发现 package-level public export | + +这是净删除,并减少同一事实漂移的可能,适合在建立外部依赖前完成。 + +## P3:删除只由测试维持的 wait helper + +### 删除证明 + +| 问题 | 证据 | +|---|---| +| 当前 authority | TUI prompt 决定允许的 restart/wait/cancel 输入;`resolveRuntimeHostCliConflictDecision()` 是唯一 production parser | +| 当前 consumers | `shouldRetryRuntimeHostConflict()` 只被 decision parser 和自己的 unit test 使用,没有独立 production caller | +| 最强保留理由 | helper 曾表达旧版 wait/cancel prompt 的语义,单独测试很直观 | +| 为什么仍可删除 | 新 parser 已完整表达三态 decision;保留旧二态函数只增加第二个输入 vocabulary 和测试面 | +| 消失内容 | 一个 export、一组仅针对旧 helper 的测试和一次嵌套 normalization | +| 新增或移动内容 | 无;wait token comparison 内联到现有 decision parser | +| 放弃的能力 | 无;`w`/`wait` 行为仍由三态 parser 测试 | +| 影响半径与不确定性 | 仅 CLI 内部模块;仓库搜索未发现外部或 production consumer | + +## Decision gates + +### Gate A:installation-owner flow 采用什么 module/record 边界? + +- **待决定的内容:** 使用一个 class、一个本地 service,还是由共享 lock/record 约束的一组深模块;selection 是 machine-wide、profile-wide 还是 installation-scoped。 +- **可能消失的 surface:** 过早选错边界会迫使每个 adapter 再维护自己的 package path、transition lock 或 fallback record;正确边界可删除这些局部 state。 +- **最强保留理由:** Desktop bundle 与 global CLI 可能本来就是两个独立 installation owner,不应仅因都连接 default State Root 就共享 executable selector。 +- **决定性缺失证据:** Desktop/CLI 的 product ownership 承诺、artifact integrity contract、跨平台 atomic switch 与 Client data layout。 + +在这些证据出现前,只固定 owner contract,不固定 `Local Installation Manager` 的具体实现形态。 + +### Gate B:`npx` 是否承诺 durable background work? + +- **待决定的内容:** invocation-owned、managed artifact 或 connect-only。 +- **可能消失的 surface:** invocation-owned 会删除 `npx` residency;managed artifact 会删除“npm cache executable 必须长期存在”的假设;connect-only 会删除 `npx` candidate spawn。 +- **最强保留理由:** 零安装启动是 `npx` 的主要价值,而 Scheduled Tasks/Goals 可能需要在 Surface 退出后继续。 +- **决定性缺失证据:** 对外 durability 承诺、cache eviction 风险接受度和真实 usage/telemetry。 + +## 已核实但不删除 + +- `compatibilityEpoch` 与 Host Generation:前者决定 contract compatibility,后者表达 exact-artifact replacement intent。 +- `generation` 与 `candidateGeneration`:前者约束现有 Host admission,后者只标记本次 election 新建的 candidate;合并会让 compatible build skew 被错误拒绝。 +- Host Epoch fencing:防止 stale updater 让错误的 replacement process 退出。 +- `host.upgrade.prepare` 与 handshake takeover:入口和 admission 条件不同,虽然最终都复用 Kernel drain。 +- Desktop、TUI、non-interactive CLI presentation:按钮、terminal prompt 和 exit behavior 是不同 Surface obligation。 +- Remote operator boundary:`connectRemoteRuntimeHostProfile()` 在 local formatter 之前处理 incompatibility;remote Client 不获得 local replacement authority。 +- Shared `_npx` detector:它已删除 service guard 与 CLI context 的重复 path inference,且有两个真实 consumer。 + +## Evidence anchors + +- `packages/cli/src/runtime-host-installation-context.ts` +- `packages/cli/src/runtime-host-installation-provenance.ts` +- `packages/cli/src/runtime-host-cli-context.ts` +- `packages/cli/src/runtime-host-tui-command.ts` +- `packages/cli/src/runtime-host-service-manager.ts` +- `packages/runtime-host/src/client/connect-or-spawn.ts` +- `packages/runtime-host/src/client/host-profile.ts` +- `packages/runtime-host/src/server/host-kernel.ts` +- `apps/desktop/src/main/runtime-host-boot.ts` +- `apps/desktop/src/main/runtime-host-desktop-manager.ts` +- `apps/desktop/src/main/app-update-service.ts` +- `packages/cli/src/__tests__/runtime-host-installation-context.test.ts` +- `packages/cli/src/__tests__/runtime-host-cli-context.test.ts` +- `packages/runtime-host/src/__tests__/host-kernel.test.ts` From 8a197b08ae50445b88ea5d671b977c821d072131 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sun, 23 Aug 2026 18:19:29 +0800 Subject: [PATCH 3/3] fix(runtime-host): drop the retired surface field from generation tests Main retired the client surface identity; the replayed generation-rejection and election tests still passed it, breaking typecheck. Generated-by: maka --- packages/runtime-host/src/__tests__/host-kernel.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/runtime-host/src/__tests__/host-kernel.test.ts b/packages/runtime-host/src/__tests__/host-kernel.test.ts index 4be0bb1f3b..fc185ea0b9 100644 --- a/packages/runtime-host/src/__tests__/host-kernel.test.ts +++ b/packages/runtime-host/src/__tests__/host-kernel.test.ts @@ -162,7 +162,6 @@ describe('non-serving Runtime Host kernel', () => { connectOrSpawnRuntimeHostWithDependencies( { rootPath: '/not-resolved', - surface: 'tui', protocol: CURRENT_PROTOCOL, compositionId: KERNEL_COMPOSITION.descriptor.id, candidateEntrypoint: KERNEL_CANDIDATE_ENTRYPOINT, @@ -194,7 +193,6 @@ describe('non-serving Runtime Host kernel', () => { const connected = await connectOrSpawnRuntimeHostWithDependencies( { rootPath: paths.root, - surface: 'tui', protocol: CURRENT_PROTOCOL, compositionId: KERNEL_COMPOSITION.descriptor.id, candidateEntrypoint: KERNEL_CANDIDATE_ENTRYPOINT, @@ -224,7 +222,6 @@ describe('non-serving Runtime Host kernel', () => { const connected = await connectOrSpawnRuntimeHostWithDependencies( { rootPath: paths.root, - surface: 'tui', protocol: CURRENT_PROTOCOL, compositionId: KERNEL_COMPOSITION.descriptor.id, candidateEntrypoint: KERNEL_CANDIDATE_ENTRYPOINT,