diff --git a/App/shell/desktop/src/main/runtime-services.ts b/App/shell/desktop/src/main/runtime-services.ts index 308f35be..1650d62f 100644 --- a/App/shell/desktop/src/main/runtime-services.ts +++ b/App/shell/desktop/src/main/runtime-services.ts @@ -839,7 +839,21 @@ async function installBundledMemoryRuntime( throw new Error(`Bundled Memory installer is missing: ${cliEntry}`); } const executable = options.runtimeExecutable ?? process.execPath; - await runBundledMemoryCli(runtimeDirectory, runtimeConfig, options, [ + await runBundledMemoryCli( + runtimeDirectory, + runtimeConfig, + options, + bundledMemoryInstallArguments(runtimeDirectory, runtimeConfig, memmyConfigPreexisting, executable) + ); +} + +export function bundledMemoryInstallArguments( + runtimeDirectory: string, + runtimeConfig: PackagedRuntimeConfig, + memmyConfigPreexisting: boolean, + executable: string +): string[] { + return [ "install", "--service-only", "--runtime-directory", runtimeDirectory, @@ -850,8 +864,9 @@ async function installBundledMemoryRuntime( "--memmy-config-preexisting", String(memmyConfigPreexisting), "--node-executable", executable, "--non-interactive", - "--use-compatible-installed" - ]); + "--use-compatible-installed", + "--health-check-timeout-ms", String(MEMORY_STARTUP_TIMEOUT_MS) + ]; } async function runBundledMemoryCli( diff --git a/App/shell/desktop/tests/runtime-services.test.ts b/App/shell/desktop/tests/runtime-services.test.ts index eb708aaa..f41b71d6 100644 --- a/App/shell/desktop/tests/runtime-services.test.ts +++ b/App/shell/desktop/tests/runtime-services.test.ts @@ -9,6 +9,7 @@ import YAML from "yaml"; import { afterEach, describe, expect, it, vi } from "vitest"; import { AgentGatewaySupervisor, + bundledMemoryInstallArguments, ensureMemoryService, preparePackagedBrowser, preparePackagedRuntimeConfig, @@ -156,6 +157,30 @@ describe("packaged desktop runtime config", () => { ]); }); + it("passes the finite Desktop startup budget to the bundled Memory installer", () => { + const args = bundledMemoryInstallArguments( + "/resources/memory", + { + configPath: "/memmy/config.yaml", + agentWorkspace: "/memmy/workspace", + memoryDatabasePath: "/memmy/memory.sqlite", + memoryBaseUrl: "http://127.0.0.1:18960", + memoryToken: "memory-token", + memoryListenHost: "127.0.0.1", + memoryListenPort: 18960, + agentGatewayBaseUrl: "http://127.0.0.1:18980", + agentGatewayHealthHost: "127.0.0.1", + agentGatewayHealthPort: 18970, + agentGatewayBootstrapSecret: "gateway-secret" + }, + true, + "/runtime/node" + ); + + expect(args.slice(-2)).toEqual(["--health-check-timeout-ms", "120000"]); + expect(args).not.toContain("--skip-health-check"); + }); + it("rejects when the packaged migration command exits unsuccessfully", async () => { const root = await makeTempRoot(); const child = Object.assign(new EventEmitter(), { diff --git a/Memory/src/cli/commands.ts b/Memory/src/cli/commands.ts index 6627700f..6afc2afc 100644 --- a/Memory/src/cli/commands.ts +++ b/Memory/src/cli/commands.ts @@ -517,6 +517,7 @@ function setupOptions(parsed: ParsedArgs): MemoryCliSetupOptions { preferInstalledCompatible: optionBoolean(parsed.options, "use-compatible-installed"), skipServiceRegistration: optionBoolean(parsed.options, "skip-service-registration"), skipHealthCheck: optionBoolean(parsed.options, "skip-health-check"), + healthCheckTimeoutMs: positiveIntegerOption(parsed, "health-check-timeout-ms"), configSource: legacyConfigSource(optionString(parsed.options, "config-source")), legacyRoot: optionString(parsed.options, "legacy-root"), nonInteractive: optionBoolean(parsed.options, "non-interactive"), @@ -537,6 +538,19 @@ function legacyConfigSource(value: string | undefined): "openclaw" | "hermes" | throw new Error("--config-source must be openclaw or hermes"); } +function positiveIntegerOption(parsed: ParsedArgs, name: string): number | undefined { + if (!hasOption(parsed.options, name)) return undefined; + const value = optionString(parsed.options, name); + if (value === undefined || !/^\d+$/.test(value)) { + throw new Error(`--${name} must be a positive integer`); + } + const parsedValue = Number(value); + if (!Number.isSafeInteger(parsedValue) || parsedValue <= 0) { + throw new Error(`--${name} must be a positive integer`); + } + return parsedValue; +} + function stringArrayOption(parsed: ParsedArgs, name: string): string[] | undefined { const value = optionString(parsed.options, name); if (value === undefined) return undefined; @@ -623,6 +637,7 @@ function helpText(): string { " --user-id Memory namespace user id", " --source Calling agent/source id", " --config Memmy config path", + " --health-check-timeout-ms Activation health timeout for Memory install", " --skip-agent-skills Initialize config without installing agent skills", " --config-source Select openclaw or hermes legacy config", " --help, -h Show this help", diff --git a/Memory/src/cli/runtime-installer.ts b/Memory/src/cli/runtime-installer.ts index 8157e6bd..0d4e8b0d 100644 --- a/Memory/src/cli/runtime-installer.ts +++ b/Memory/src/cli/runtime-installer.ts @@ -11,6 +11,7 @@ import { MEMORY_PROTOCOL_VERSION, MEMORY_SERVICE_VERSION } from "../version.js"; const DEFAULT_RELEASES_URL = "https://github.com/MemTensor/memmy-agent/releases"; const INSTALL_LOCK_TIMEOUT_MS = 15_000; const SERVICE_STOP_TIMEOUT_MS = 5_000; +export const DEFAULT_HEALTH_CHECK_TIMEOUT_MS = 120_000; export interface RuntimeAssetDescriptor { name: string; sha256: string; size?: number; url?: string; } export interface MemoryReleaseManifest { @@ -33,6 +34,8 @@ export interface MemoryRuntimeInstallOptions { nodeExecutable?: string; skipServiceRegistration?: boolean; skipHealthCheck?: boolean; + /** Maximum time to wait for the newly activated service to report its version. */ + healthCheckTimeoutMs?: number; endpoint?: string; agents?: string[]; /** Desktop uses a newer compatible installation instead of replacing it with its bundled copy. */ @@ -50,6 +53,7 @@ export interface InstalledRuntimePointer { } export async function installMemoryRuntime(options: MemoryRuntimeInstallOptions = {}): Promise> { + const healthCheckTimeoutMs = resolveHealthCheckTimeoutMs(options.healthCheckTimeoutMs); const home = resolveHome(options.home ?? "~/.memmy"); const serviceHome = join(home, "memory-service"); const runtimeRoot = join(serviceHome, "runtime"); @@ -61,10 +65,11 @@ export async function installMemoryRuntime(options: MemoryRuntimeInstallOptions throw new Error(`Memory protocol ${manifest.protocolVersion} is incompatible with installer protocol ${MEMORY_PROTOCOL_VERSION}`); } const currentPath = join(serviceHome, "current.json"); + const installationPath = join(serviceHome, "installation.json"); const previous = await readJsonFile(currentPath); const versionComparison = previous ? compareVersions(manifest.version, previous.version) : 1; if (previous && options.preferInstalledCompatible && previous.protocolVersion === MEMORY_PROTOCOL_VERSION && versionComparison <= 0) { - return reuseInstalledRuntime(previous, home, serviceHome, options); + return reuseInstalledRuntime(previous, home, serviceHome, options, healthCheckTimeoutMs); } if (previous && versionComparison < 0) { throw new Error(`refusing to downgrade Memory from ${previous.version} to ${manifest.version}`); @@ -87,6 +92,7 @@ export async function installMemoryRuntime(options: MemoryRuntimeInstallOptions await mkdir(runtimeRoot, { recursive: true }); const installLock = await acquireInstallLock(join(serviceHome, "install.lock")); let stagedPath: string | undefined; + let installedRuntimeCreated = false; try { if (!existsSync(pointer.entrypoint)) { stagedPath = join(runtimeRoot, `.staging-${process.pid}-${Date.now()}`); @@ -108,6 +114,7 @@ export async function installMemoryRuntime(options: MemoryRuntimeInstallOptions await mkdir(dirname(runtimeDir), { recursive: true }); await rm(runtimeDir, { recursive: true, force: true }); await rename(unpacked, runtimeDir); + installedRuntimeCreated = true; } else { await validateRuntime(runtimeDir, manifest.version, target, manifest.protocolVersion); } @@ -120,21 +127,33 @@ export async function installMemoryRuntime(options: MemoryRuntimeInstallOptions if (!options.skipHealthCheck) { try { - await waitForRuntimeHealth(options.endpoint ?? "http://127.0.0.1:18960", manifest.version); + await waitForRuntimeHealth( + options.endpoint ?? "http://127.0.0.1:18960", + manifest.version, + healthCheckTimeoutMs + ); } catch (error) { - if (!options.skipServiceRegistration) stopUserService(); + if (!options.skipServiceRegistration && previous) stopUserService(); if (previous) { await writeJsonAtomic(currentPath, previous); await writeStableLauncher(home, serviceHome, previous.runtimeExecutable ?? process.execPath); if (!options.skipServiceRegistration) registerAndStartUserService(home, serviceHome); } else { - await unlink(currentPath).catch(() => undefined); + await cleanupFailedFirstInstall({ + currentPath, + installationPath, + launcher, + runtimeDir, + runtimeCreated: installedRuntimeCreated, + serviceHome, + unregisterService: !options.skipServiceRegistration + }); } throw error; } } - await writeJsonAtomic(join(serviceHome, "installation.json"), { + await writeJsonAtomic(installationPath, { serviceVersion: manifest.version, protocolVersion: manifest.protocolVersion, target, @@ -334,7 +353,8 @@ async function reuseInstalledRuntime( pointer: InstalledRuntimePointer, home: string, serviceHome: string, - options: MemoryRuntimeInstallOptions + options: MemoryRuntimeInstallOptions, + healthCheckTimeoutMs: number ): Promise> { if (options.dryRun) return { ok: true, reused: true, dryRun: true, ...pointer }; await validateRuntime(pointer.runtimeDir, pointer.version, pointer.target, pointer.protocolVersion); @@ -344,7 +364,11 @@ async function reuseInstalledRuntime( } if (!options.skipServiceRegistration) registerAndStartUserService(home, serviceHome); if (!options.skipHealthCheck) { - await waitForRuntimeHealth(options.endpoint ?? "http://127.0.0.1:18960", pointer.version); + await waitForRuntimeHealth( + options.endpoint ?? "http://127.0.0.1:18960", + pointer.version, + healthCheckTimeoutMs + ); } return { ok: true, reused: true, ...pointer }; } @@ -588,12 +612,19 @@ function runLifecycle(command: string, args: string[], allowFailure = false): vo } } -async function waitForRuntimeHealth(endpoint: string, expectedVersion: string): Promise { - const deadline = Date.now() + 15_000; +async function waitForRuntimeHealth( + endpoint: string, + expectedVersion: string, + timeoutMs: number +): Promise { + const deadline = Date.now() + timeoutMs; let lastError = "service did not respond"; while (Date.now() < deadline) { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) break; + const requestTimeoutMs = Math.max(1, Math.min(1_000, remainingMs)); try { - const response = await fetch(`${endpoint.replace(/\/$/, "")}/api/v1/health`, { signal: AbortSignal.timeout(1_000) }); + const response = await fetch(`${endpoint.replace(/\/$/, "")}/api/v1/health`, { signal: AbortSignal.timeout(requestTimeoutMs) }); if (response.ok) { const health = await response.json() as Record; if ( @@ -612,10 +643,62 @@ async function waitForRuntimeHealth(endpoint: string, expectedVersion: string): } catch (error) { lastError = error instanceof Error ? error.message : String(error); } - await new Promise((resolveDelay) => setTimeout(resolveDelay, 250)); + const delayMs = Math.min(250, Math.max(0, deadline - Date.now())); + if (delayMs <= 0) break; + await new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs)); } throw new Error(`Memory ${expectedVersion} failed its activation health check: ${lastError}`); } + +async function cleanupFailedFirstInstall(input: { + currentPath: string; + installationPath: string; + launcher: { command: string; script: string }; + runtimeDir: string; + runtimeCreated: boolean; + serviceHome: string; + unregisterService: boolean; +}): Promise { + if (input.unregisterService) { + await removeUserServiceRegistration(); + } + await Promise.all([ + rm(input.currentPath, { force: true }).catch(() => undefined), + rm(input.launcher.command, { force: true }).catch(() => undefined), + rm(input.launcher.script, { force: true }).catch(() => undefined), + rm(input.installationPath, { force: true }).catch(() => undefined), + rm(join(input.serviceHome, "runtime.json"), { force: true }).catch(() => undefined), + ...(input.runtimeCreated + ? [rm(input.runtimeDir, { recursive: true, force: true }).catch(() => undefined)] + : []) + ]); +} + +async function removeUserServiceRegistration(): Promise { + if (process.platform === "darwin") { + runLifecycle("launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/com.memtensor.memmy-memory`], true); + await rm(join(homedir(), "Library", "LaunchAgents", "com.memtensor.memmy-memory.plist"), { force: true }).catch(() => undefined); + return; + } + if (process.platform === "linux") { + runLifecycle("systemctl", ["--user", "disable", "--now", "memmy-memory.service"], true); + await rm(join(homedir(), ".config", "systemd", "user", "memmy-memory.service"), { force: true }).catch(() => undefined); + runLifecycle("systemctl", ["--user", "daemon-reload"], true); + return; + } + if (process.platform === "win32") { + runLifecycle("schtasks", ["/End", "/TN", "Memmy Memory Service"], true); + runLifecycle("schtasks", ["/Delete", "/TN", "Memmy Memory Service", "/F"], true); + } +} + +function resolveHealthCheckTimeoutMs(value: number | undefined): number { + if (value === undefined) return DEFAULT_HEALTH_CHECK_TIMEOUT_MS; + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error("healthCheckTimeoutMs must be a positive integer"); + } + return value; +} async function acquireInstallLock(path: string): Promise<{ release(): Promise }> { await mkdir(dirname(path), { recursive: true }); const startedAt = Date.now(); diff --git a/Memory/tests/cli-command-map.test.ts b/Memory/tests/cli-command-map.test.ts index d546cba3..7fd76081 100644 --- a/Memory/tests/cli-command-map.test.ts +++ b/Memory/tests/cli-command-map.test.ts @@ -30,6 +30,7 @@ describe("memmy CLI command map", () => { expect(help).toContain("init --agent codex"); expect(help).toContain("init --skip-agent-skills"); expect(help).toContain("--skip-agent-skills"); + expect(help).toContain("--health-check-timeout-ms"); expect(help).toContain("Supported agents:"); expect(help).toContain("Default URL:"); }); @@ -39,6 +40,34 @@ describe("memmy CLI command map", () => { await expect(runCommand({ argv: ["-v"] })).resolves.toBe(PROJECT_VERSION); }); + it("passes a valid installer health timeout and rejects malformed values", async () => { + const root = mkdtempSync(join(tmpdir(), "memmy-cli-timeout-")); + roots.push(root); + await expect(runCommand({ + argv: [ + "install", + "--dry-run", + "--service-only", + "--home", root, + "--health-check-timeout-ms", "1234" + ] + })).resolves.toMatchObject({ + ok: true, + command: "install", + runtime: { ok: true, dryRun: true } + }); + + await expect(runCommand({ + argv: [ + "install", + "--dry-run", + "--service-only", + "--home", root, + "--health-check-timeout-ms", "0" + ] + })).rejects.toThrow("--health-check-timeout-ms must be a positive integer"); + }); + it("supports memmy-memory stop as an alias for service stop", async () => { const stop = vi.fn(async (home: string) => ({ ok: true, action: "stop", home })); diff --git a/Memory/tests/runtime-installer.test.ts b/Memory/tests/runtime-installer.test.ts index 3e5f0004..93f63faa 100644 --- a/Memory/tests/runtime-installer.test.ts +++ b/Memory/tests/runtime-installer.test.ts @@ -1,13 +1,14 @@ import { createHash } from "node:crypto"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; import { createServer } from "node:http"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { compareVersions, currentInstalledRuntime, + DEFAULT_HEALTH_CHECK_TIMEOUT_MS, installMemoryRuntime, runtimeTarget, stopInstalledMemoryService, @@ -141,6 +142,188 @@ describe("standalone Memory runtime installer", () => { expect(await currentInstalledRuntime(home)).toBeUndefined(); }); + it("waits for a migration-delayed health response instead of failing at 15 seconds", async () => { + expect(DEFAULT_HEALTH_CHECK_TIMEOUT_MS).toBe(120_000); + const root = tempRoot(); + const home = join(root, "home"); + const runtimeDirectory = createRuntimeDirectory(root, "2.1.0"); + vi.useFakeTimers(); + let resolveStarted!: () => void; + const started = new Promise((resolve) => { + resolveStarted = resolve; + }); + const startedAt = Date.now(); + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + resolveStarted(); + const ready = Date.now() - startedAt >= 16_000; + return { + ok: true, + json: async () => ready + ? { ok: true, protocolVersion: 1, serviceVersion: "2.1.0" } + : { ok: true, protocolVersion: 1, serviceVersion: "old" }, + } as Response; + }); + + try { + const install = installMemoryRuntime({ + home, + runtimeDirectory, + endpoint: "http://127.0.0.1:18960", + skipServiceRegistration: true, + }); + await started; + await vi.advanceTimersByTimeAsync(16_000); + await expect(install).resolves.toMatchObject({ ok: true, version: "2.1.0" }); + } finally { + vi.restoreAllMocks(); + vi.useRealTimers(); + } + }); + + it("caps retry backoff at the activation health deadline", async () => { + const root = tempRoot(); + const home = join(root, "home"); + const runtimeDirectory = createRuntimeDirectory(root, "2.1.0"); + vi.useFakeTimers(); + let resolveStarted!: () => void; + const started = new Promise((resolve) => { + resolveStarted = resolve; + }); + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + resolveStarted(); + return { + ok: true, + json: async () => ({ ok: true, protocolVersion: 1, serviceVersion: "old" }) + } as Response; + }); + + try { + const install = installMemoryRuntime({ + home, + runtimeDirectory, + endpoint: "http://127.0.0.1:18960", + skipServiceRegistration: true, + healthCheckTimeoutMs: 1 + }); + await started; + await vi.advanceTimersByTimeAsync(1); + expect(vi.getTimerCount()).toBe(0); + await expect(install).rejects.toThrow("activation health check"); + } finally { + vi.restoreAllMocks(); + vi.useRealTimers(); + } + }); + + it("restores the previous runtime when the bounded activation health timeout expires", async () => { + const root = tempRoot(); + const home = join(root, "home"); + const previousDirectory = createRuntimeDirectory(root, "2.0.0"); + await installMemoryRuntime({ + home, + runtimeDirectory: previousDirectory, + skipServiceRegistration: true, + skipHealthCheck: true + }); + const nextDirectory = createRuntimeDirectory(root, "2.1.0"); + vi.useFakeTimers(); + let resolveStarted!: () => void; + const started = new Promise((resolve) => { + resolveStarted = resolve; + }); + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + resolveStarted(); + return { + ok: true, + json: async () => ({ ok: true, protocolVersion: 1, serviceVersion: "old" }) + } as Response; + }); + + try { + const install = installMemoryRuntime({ + home, + runtimeDirectory: nextDirectory, + endpoint: "http://127.0.0.1:18960", + skipServiceRegistration: true, + healthCheckTimeoutMs: 1_000 + }); + await started; + await vi.advanceTimersByTimeAsync(2_000); + await expect(install).rejects.toThrow("activation health check"); + expect((await currentInstalledRuntime(home))?.version).toBe("2.0.0"); + expect(JSON.parse(readFileSync(join(home, "memory-service", "installation.json"), "utf8"))).toMatchObject({ + serviceVersion: "2.0.0" + }); + } finally { + vi.restoreAllMocks(); + vi.useRealTimers(); + } + }); + + it("cleans first-install launchers and metadata when activation health times out", async () => { + const root = tempRoot(); + const home = join(root, "home"); + const runtimeDirectory = createRuntimeDirectory(root, "2.1.0"); + const launcherName = process.platform === "win32" + ? "memmy-memory-service.cmd" + : "memmy-memory-service"; + mkdirSync(join(home, "bin"), { recursive: true }); + writeFileSync(join(home, "bin", launcherName), "stale launcher"); + writeFileSync(join(home, "bin", "memmy-memory-service.cjs"), "stale script"); + mkdirSync(join(home, "memory-service"), { recursive: true }); + writeFileSync(join(home, "memory-service", "installation.json"), JSON.stringify({ + serviceVersion: "stale" + })); + writeFileSync(join(home, "memory-service", "runtime.json"), JSON.stringify({ + endpoint: "http://127.0.0.1:18960" + })); + vi.useFakeTimers(); + let resolveStarted!: () => void; + const started = new Promise((resolve) => { + resolveStarted = resolve; + }); + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + resolveStarted(); + return { + ok: true, + json: async () => ({ ok: true, protocolVersion: 1, serviceVersion: "old" }) + } as Response; + }); + + try { + const install = installMemoryRuntime({ + home, + runtimeDirectory, + endpoint: "http://127.0.0.1:18960", + skipServiceRegistration: true, + healthCheckTimeoutMs: 1_000 + }); + await started; + await vi.advanceTimersByTimeAsync(2_000); + await expect(install).rejects.toThrow("activation health check"); + expect(await currentInstalledRuntime(home)).toBeUndefined(); + expect(existsSync(join(home, "bin", launcherName))).toBe(false); + expect(existsSync(join(home, "bin", "memmy-memory-service.cjs"))).toBe(false); + expect(existsSync(join(home, "memory-service", "installation.json"))).toBe(false); + expect(existsSync(join(home, "memory-service", "runtime.json"))).toBe(false); + expect(existsSync(join(home, "memory-service", "runtime", "2.1.0", runtimeTarget(process.platform, process.arch)))).toBe(false); + } finally { + vi.restoreAllMocks(); + vi.useRealTimers(); + } + }); + + it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( + "rejects an invalid activation health timeout (%s)", + async (healthCheckTimeoutMs) => { + await expect(installMemoryRuntime({ + home: tempRoot(), + dryRun: true, + healthCheckTimeoutMs + })).rejects.toThrow("healthCheckTimeoutMs must be a positive integer"); + } + ); + it("never replaces a newer installed version with an older one", async () => { const root = tempRoot(); const home = join(root, "home");