You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/06 11:15:13 UTC

(maka) branch main updated: fix(desktop): recover outdated managed host storage at startup (#4918)

This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git


The following commit(s) were added to refs/heads/main by this push:
     new df030ab28b fix(desktop): recover outdated managed host storage at startup (#4918)
df030ab28b is described below

commit df030ab28b8532c63121130c28032430be326984
Author: Wang <m4...@gmail.com>
AuthorDate: Sun Sep 6 19:15:08 2026 +0800

    fix(desktop): recover outdated managed host storage at startup (#4918)
    
    Validate local storage before admitting the Host candidate and route required migrations through managed startup recovery without granting Desktop migration authority.
    
    Generated-by: Codex
---
 .../main/__tests__/runtime-host-client-uds.test.ts |  4 ++
 .../runtime-host-desktop-candidate.test.ts         | 80 ++++++++++++++++++++++
 .../runtime-host-startup-recovery.test.ts          | 14 ++--
 .../src/main/runtime-host-desktop-candidate.ts     |  5 ++
 .../src/main/runtime-host-startup-recovery.ts      |  4 ++
 .../src/__tests__/operational-state-store.test.ts  |  8 +++
 .../storage/src/operational-state-store-public.ts  |  1 +
 packages/storage/src/operational-state-store.ts    | 11 ++-
 8 files changed, 121 insertions(+), 6 deletions(-)

diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts
index da520348ce..f287676e77 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts
@@ -27,6 +27,7 @@ import type { BotRegistry } from '@maka/runtime/bots';
 import type { ComputerUseToolSet } from '@maka/runtime/computer-use-tools';
 import type { MakaTool } from '@maka/runtime/tool-runtime';
 import { connectRuntimeHost } from '@maka/runtime-host/client';
+import { acquireOperationalStateDatabase } from '@maka/storage/operational-state-store';
 import {
   RUNTIME_HOST_PROTOCOL_VERSION,
   type SessionCatalogProjection,
@@ -250,6 +251,9 @@ test('drives the renderer Session catalog facade through real UDS framing', asyn
     });
     const ipc = ipcHarness();
     const changes: Array<{ reason: string; sessionId?: string }> = [];
+    // This fixture replaces the real execution composition; initialize its
+    // owned storage before Desktop admits the local candidate.
+    acquireOperationalStateDatabase(base).close();
     const started = await startDesktopRuntimeHostCandidate({
       rootPath: base,
       candidateEntrypoint: new URL('file:///unused-runtime-host-candidate.js'),
diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts
index 6a71834883..03c14672be 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts
@@ -19,7 +19,12 @@
 
 import assert from 'node:assert/strict';
 import { EventEmitter } from 'node:events';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { DatabaseSync } from 'node:sqlite';
 import test from 'node:test';
+import { acquireOperationalStateDatabase } from '@maka/storage/operational-state-store';
 import type { IpcMain } from 'electron';
 import type { BotIncomingMessage, BotRegistry } from '@maka/runtime/bots';
 import type { ComputerUseToolSet } from '@maka/runtime/computer-use-tools';
@@ -55,6 +60,7 @@ import { RuntimeHostSessionObservationRegistry } from '../runtime-host-session-o
 import { RuntimeHostReconnectingIpcMain } from '../runtime-host-reconnecting-ipc-main.js';
 import { desktopSessionResourceKey } from '../../shared/runtime-host-identity.js';
 import { waitFor as pollFor } from '@maka/core/test-only/async-primitives';
+import { startDesktopRuntimeHostWithRecovery } from '../runtime-host-startup-recovery.js';
 
 const TEST_HOST_ID = 'a'.repeat(64);
 const TEST_TARGET_EPOCH = 'test-target-epoch';
@@ -84,6 +90,80 @@ test('uses the manager-owned launch barrier for local candidate startup', async
   assert.equal(connectedRoot, 'C:\\workspace');
 });
 
+test('updates a protocol-compatible managed Host before exposing a candidate over its old storage', async (t) => {
+  const root = await mkdtemp(join(tmpdir(), 'maka-desktop-managed-schema-'));
+  t.after(() => rm(root, { recursive: true, force: true }));
+  acquireOperationalStateDatabase(root).close();
+  const databasePath = join(root, 'runtime.sqlite');
+  const legacy = new DatabaseSync(databasePath);
+  legacy.exec(`
+    DROP TABLE usage_model_call_attempts;
+    CREATE TABLE usage_model_call_attempts (
+      attempt_id TEXT PRIMARY KEY,
+      completed_at INTEGER NOT NULL,
+      record_json TEXT NOT NULL,
+      session_id TEXT
+    );
+    INSERT INTO usage_model_call_attempts VALUES ('retained', 1, '{}', 'deleted-session');
+    UPDATE operational_schema_migrations SET version = 6 WHERE scope = 'usage';
+  `);
+  legacy.close();
+  const ipc = ipcHarness();
+  const old = connectionHarness('old');
+  const updated = connectionHarness('updated');
+  let starts = 0;
+  let repairs = 0;
+  const candidate = await startDesktopRuntimeHostWithRecovery({
+    start: async () => {
+      const host = starts++ === 0 ? old : updated;
+      const result = await startDesktopRuntimeHostCandidate({
+        ...deps(ipc),
+        workspaceRoot: root,
+        rootPath: root,
+        candidateEntrypoint: 'unused.js',
+        candidateLaunchBarrier: {
+          connect: async () => ({
+            kind: 'connected',
+            connection: host.connection,
+            registration: { lifecycleMode: 'supervised', pid: 123 },
+          }),
+        },
+      } as unknown as DesktopRuntimeHostCandidateStartInput);
+      assert.equal(result.kind, 'ready');
+      if (result.kind !== 'ready') throw new Error('Expected a ready candidate');
+      return result.candidate;
+    },
+    repair: async (authority) => {
+      repairs += 1;
+      assert.deepEqual(authority, { allowManualUpdate: false, allowInterruptActiveTasks: false });
+      assert.equal(old.closeCalls, 1, 'release the old connection before managed update');
+      assert.equal(old.capabilityRegistrations, 0, 'do not expose capabilities before storage admission');
+      assert.equal(ipc.size, 0);
+      const preserved = new DatabaseSync(databasePath, { readOnly: true });
+      try {
+        assert.equal(preserved.prepare("SELECT version FROM operational_schema_migrations WHERE scope = 'usage'").get()?.version, 6);
+        assert.equal(preserved.prepare("SELECT record_json FROM usage_model_call_attempts WHERE attempt_id = 'retained'").get()?.record_json, '{}');
+      } finally {
+        preserved.close();
+      }
+      // Simulate the updated owning Host, not Desktop, performing migration.
+      acquireOperationalStateDatabase(root).close();
+      return { kind: 'repaired' };
+    },
+    prompt: async () => { throw new Error('No prompt needed for an idle automatically updatable Host'); },
+  });
+  t.after(() => candidate.close());
+  assert.equal(starts, 2);
+  assert.equal(repairs, 1);
+  assert.equal(updated.capabilityRegistrations, 1);
+  const current = acquireOperationalStateDatabase(root, { schemaMigration: 'require_current' });
+  try {
+    assert.equal(current.database.prepare("SELECT session_id FROM usage_model_call_attempts WHERE attempt_id = 'retained'").get()?.session_id, 'deleted-session');
+  } finally {
+    current.close();
+  }
+});
+
 test('formats bounded local Host exit evidence without leaking stderr secrets', () => {
   const diagnostic = formatLocalRuntimeHostProcessExitDiagnostic(42, {
     code: 23,
diff --git a/apps/desktop/src/main/__tests__/runtime-host-startup-recovery.test.ts b/apps/desktop/src/main/__tests__/runtime-host-startup-recovery.test.ts
index 8074ee6915..8b8cc43a91 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-startup-recovery.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-startup-recovery.test.ts
@@ -20,6 +20,7 @@
 import assert from "node:assert/strict";
 import test from "node:test";
 import { runtimeHostStartupError } from "@maka/runtime-host/client";
+import { OperationalStateMigrationBlockedError } from '@maka/storage/operational-state-store';
 import { startDesktopRuntimeHostWithRecovery } from "../runtime-host-startup-recovery.js";
 
 test("repairs a managed Host once and resumes startup without asking the user", async () => {
@@ -55,7 +56,10 @@ test("repairs a managed Host once and resumes startup without asking the user",
   assert.equal(prompts, 0);
 });
 
-test("separates manual update consent from active-work interruption", async () => {
+for (const failure of [
+  runtimeHostStartupError('managed_root_requires_operator'),
+  new OperationalStateMigrationBlockedError(new Error('Host migration required'), 'requires_host_migration'),
+]) test(`separates manual update consent from active-work interruption: ${failure.name}`, async () => {
   let starts = 0;
   const repairModes: Array<{
     readonly allowManualUpdate: boolean;
@@ -67,7 +71,7 @@ test("separates manual update consent from active-work interruption", async () =
     start: async () => {
       starts += 1;
       if (starts === 1)
-        throw runtimeHostStartupError("managed_root_requires_operator");
+        throw failure;
       return "ready";
     },
     repair: async (authority) => {
@@ -92,8 +96,10 @@ test("separates manual update consent from active-work interruption", async () =
   assert.deepEqual(prompts, [false, true]);
 });
 
-test("does not offer managed repair for an unrelated startup failure", async () => {
-  const failure = new Error("renderer prerequisites failed");
+for (const failure of [
+  new Error('renderer prerequisites failed'),
+  new OperationalStateMigrationBlockedError(new Error('unsupported newer schema')),
+]) test(`does not offer managed repair for an unrelated startup failure: ${failure.name}`, async () => {
   let repairs = 0;
   let prompts = 0;
 
diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts
index e323bf6c4a..9d41e42e0e 100644
--- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts
+++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts
@@ -18,6 +18,7 @@
  */
 
 import { randomUUID } from "node:crypto";
+import { acquireOperationalStateDatabase } from '@maka/storage/operational-state-store';
 import type { IpcMain } from "electron";
 import type { ActiveInteractionRequestEvent } from '@maka/core/events';
 import { redactSecrets } from '@maka/core/redaction';
@@ -344,6 +345,10 @@ export async function startDesktopRuntimeHostCandidate(
   if (connection.kind !== "connected") return connection;
   observeLocalRuntimeHostProcess(connection.spawnedProcess);
   try {
+    // A resident managed Host can speak this protocol while still using an
+    // older storage schema. Validate the shared local database before exposing
+    // any candidate services, so startup recovery can update its owning Host.
+    acquireOperationalStateDatabase(input.rootPath, { schemaMigration: 'require_current' }).close();
     return {
       kind: "ready",
       candidate: await createDesktopRuntimeHostCandidate(
diff --git a/apps/desktop/src/main/runtime-host-startup-recovery.ts b/apps/desktop/src/main/runtime-host-startup-recovery.ts
index 3f6b282986..5d68697609 100644
--- a/apps/desktop/src/main/runtime-host-startup-recovery.ts
+++ b/apps/desktop/src/main/runtime-host-startup-recovery.ts
@@ -18,6 +18,7 @@
  */
 
 import { RuntimeHostStartupError } from "@maka/runtime-host/client";
+import { OperationalStateMigrationBlockedError } from '@maka/storage/operational-state-store';
 
 export type DesktopRuntimeHostStartupRepairResult =
   | { readonly kind: "repaired" }
@@ -121,6 +122,9 @@ export async function startDesktopRuntimeHostWithRecovery<T>(input: {
 }
 
 export function canRepairManagedRuntimeHostStartup(error: Error): boolean {
+  if (error instanceof OperationalStateMigrationBlockedError) {
+    return error.reason === 'requires_host_migration';
+  }
   return (
     error instanceof RuntimeHostStartupError &&
     (error.reason === "managed_root_requires_operator" ||
diff --git a/packages/storage/src/__tests__/operational-state-store.test.ts b/packages/storage/src/__tests__/operational-state-store.test.ts
index f6274475f6..1264656455 100644
--- a/packages/storage/src/__tests__/operational-state-store.test.ts
+++ b/packages/storage/src/__tests__/operational-state-store.test.ts
@@ -126,6 +126,7 @@ test('a non-owner rejects an older schema without migrating it behind the Runtim
         }),
       (error: unknown) =>
         error instanceof OperationalStateMigrationBlockedError &&
+        error.reason === 'requires_host_migration' &&
         /requires migration by its Runtime Host/u.test(error.message),
     );
 
@@ -1295,6 +1296,13 @@ test('rejects a newer scope before migrating an older scope', async () => {
       () => acquireOperationalStateDatabase(root),
       /Operational schema usage is newer than supported/,
     );
+    assert.throws(
+      () => acquireOperationalStateDatabase(root, { schemaMigration: 'require_current' }),
+      (error: unknown) =>
+        error instanceof OperationalStateMigrationBlockedError &&
+        error.reason === 'blocked' &&
+        /Operational schema usage is newer than supported/u.test(error.message),
+    );
 
     const preserved = new DatabaseSync(databasePath, { readOnly: true });
     try {
diff --git a/packages/storage/src/operational-state-store-public.ts b/packages/storage/src/operational-state-store-public.ts
index 1fd6c8b6d9..bacb95b2c3 100644
--- a/packages/storage/src/operational-state-store-public.ts
+++ b/packages/storage/src/operational-state-store-public.ts
@@ -26,6 +26,7 @@
  */
 export {
   acquireOperationalStateDatabase,
+  OperationalStateMigrationBlockedError,
   OPERATIONAL_STATE_DATABASE_NAME,
   OPERATIONAL_STATE_SCHEMA_VERSION,
   resolveOperationalStateDatabasePath,
diff --git a/packages/storage/src/operational-state-store.ts b/packages/storage/src/operational-state-store.ts
index 63faeba31c..77def1c7b0 100644
--- a/packages/storage/src/operational-state-store.ts
+++ b/packages/storage/src/operational-state-store.ts
@@ -153,7 +153,10 @@ export interface OperationalStateDatabaseOptions {
 export class OperationalStateMigrationBlockedError extends Error {
   readonly code = 'operational_state_migration_blocked';
 
-  constructor(cause: unknown) {
+  constructor(
+    cause: unknown,
+    readonly reason: 'requires_host_migration' | 'blocked' = 'blocked',
+  ) {
     super(cause instanceof Error ? cause.message : 'Operational state migration is blocked', {
       cause,
     });
@@ -202,6 +205,7 @@ class OperationalStateDatabaseOwner {
     if (options.schemaMigration === 'require_current' && !existsSync(databasePath)) {
       throw new OperationalStateMigrationBlockedError(
         new Error('Operational state has not been initialized by its Runtime Host'),
+        'requires_host_migration',
       );
     }
     mkdirSync(dirname(databasePath), { recursive: true });
@@ -291,7 +295,10 @@ function requireCurrentOperationalState(database: DatabaseSync): void {
   try {
     const inspection = inspectOperationalStateSchema(database);
     if (inspection.status === 'current' && isCurrentOperationalTargetSchema(database)) return;
-    throw new Error('Operational state requires migration by its Runtime Host');
+    throw new OperationalStateMigrationBlockedError(
+      new Error('Operational state requires migration by its Runtime Host'),
+      'requires_host_migration',
+    );
   } catch (error) {
     if (isSqliteEnvironmentError(error)) throw error;
     if (error instanceof OperationalStateMigrationBlockedError) throw error;


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org