Compare commits

..
Author SHA1 Message Date
alvinfeiandClaude Opus 5.5 ef5ff06590 fix(sdk): intention dedup switch also gates write-batch dedup
When intention_dedup_enabled is off, no intention dedup should run, but
the writer's batch dedup still dropped near-duplicate intentions from
one extraction. Gate it on the same switch. Its threshold stays on
MEMORY_DEDUP_THRESHOLD.

Also skip the embedding backfill when dedup is off, since only dedup
uses it, and fall back to the env default on an invalid configured
threshold so a bad value cannot fail the read.

Refs #241

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 12:33:56 +08:00
alvinfeiandClaude Opus 5.5 2624228f6f feat(sdk): configurable intention dedup (on by default, unchanged behaviour)
Add MemoryConfig.recall.intention_dedup_enabled (default true, env
MEMORY_INTENTION_DEDUP_ENABLED) and recall.intention_dedup_threshold
(env MEMORY_INTENTION_DEDUP_THRESHOLD, falling back to the shared
MEMORY_DEDUP_THRESHOLD, then 0.95). The reader passes both from the
client config to recall_intentions. When off, read-time intention dedup
is skipped entirely: nothing is hidden and nothing is deleted.

Refs #241

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 12:25:37 +08:00
37 changed files with 520 additions and 2043 deletions

No files matched your search

+9
View File
@@ -4,6 +4,15 @@ Hy Memory and MemHub are versioned and released independently.
## Unreleased
- SDK: Intention dedup is now configurable per client
(`MemoryConfig.recall.intention_dedup_enabled`, default `true`;
`recall.intention_dedup_threshold`, default `MEMORY_DEDUP_THRESHOLD` or
`0.95`). Env defaults: `MEMORY_INTENTION_DEDUP_ENABLED`,
`MEMORY_INTENTION_DEDUP_THRESHOLD`. When on, behaviour is unchanged
(duplicates found at read time are still deleted); when off, no intention
dedup runs, at read time or within a write batch, and nothing is hidden,
dropped or deleted. The threshold applies to read-time dedup only. Refs #241.
## MemHub 1.0.6 — 2026-11-02
Build 37, on `hy-memory==2.0.11`. Linux x64/arm64 Runtime and macOS arm64
+20
View File
@@ -182,9 +182,29 @@ SDK 不设置 Rerank Search 并发上限。开启后模型在 `HyMemoryClient`
| `MEMORY_RECALL_STRENGTH_TAU` | `180` | 衰减时间常数,天 |
| `MEMORY_RECALL_ACCESS_TRACKING` | `true` | Strength 开启时是否回写访问信息 |
| `MEMORY_ENTITY_STORE_ENABLED` | `false` | 是否维护独立 entity collection;当前 Reader 不直接读取 |
| `MEMORY_INTENTION_DEDUP_ENABLED` | `true` | 是否对 L7 Intention 判重(读侧和写入侧批内);见下文 |
| `MEMORY_INTENTION_DEDUP_THRESHOLD` | `MEMORY_DEDUP_THRESHOLD`,再缺省为 `0.95` | Intention 读侧判重的 cosine 阈值 |
旧数据可调用当前公开方法 `client.build_entity_store(user_id)` 补建 Entity Store。
Intention 判重:开启时(默认,与以往行为一致),Search 召回的 L7 Intention 中,
同 `user_id` + 同 `agent_id` + `memory_at` 同一天 + `valid_until` 相同、且 cosine ≥ 阈值的
条目视为复本,只返回最早写入的一条,其余在后台从向量库**删除**。判重不区分内容中的项目、
对象等语义差异,内容高度相似但对象不同的意图可能被误删;写入时同一批抽取结果也会做一次
批内判重。关闭后这两处都不判重,不隐藏、不丢弃、也不删除意图。两项对应
`MemoryConfig.recall.intention_dedup_enabled` /
`recall.intention_dedup_threshold`,按 client 生效,环境变量只提供默认值:
```python
client = HyMemoryClient.from_config({
"recall": {"intention_dedup_enabled": False},
})
```
`MEMORY_DEDUP_THRESHOLD` 同时被写入侧批内意图判重和 dedup 内核使用;
`MEMORY_INTENTION_DEDUP_THRESHOLD` 只影响读侧 Intention 判重,写入侧批内判重的阈值
仍取 `MEMORY_DEDUP_THRESHOLD`。
## 日志、Trace 与历史
| 变量 | 默认值 | 说明 |
+19
View File
@@ -432,6 +432,14 @@ class RecallConfig:
retrieval_clauses_require_keyword: bool = None # env: MEMORY_RETRIEVAL_CLAUSES_REQUIRE_KEYWORD
retrieval_clauses_min_keyword_score: float = None # normalized BM25 floor
supplemental_propositions_enabled: bool = None # env: MEMORY_SUPPLEMENTAL_PROPOSITIONS_ENABLED
# ── Intention(L7)判重 ──
# 开启(默认):召回时同 user+agent+同天 memory_at+同 valid_until 且
# cosine≥阈值 的意图视为复本,只返回最早一条,其余异步从 VDB 删除(历史行为)。
# 关闭:读侧与写入侧批内都不判重——不隐藏、不丢弃、不删除任何意图。
intention_dedup_enabled: bool = None # env: MEMORY_INTENTION_DEDUP_ENABLED
# 读侧意图判重 cosine 阈值。env: MEMORY_INTENTION_DEDUP_THRESHOLD,
# 未设置时回退到共享的 MEMORY_DEDUP_THRESHOLD(兼容旧部署),默认 0.95。
intention_dedup_threshold: float = None
# 最小分数阈值
min_score_threshold: float = 0.0
@@ -750,6 +758,15 @@ class RecallConfig:
self.supplemental_propositions_enabled = _get_env_bool(
"MEMORY_SUPPLEMENTAL_PROPOSITIONS_ENABLED", False
)
if self.intention_dedup_enabled is None:
self.intention_dedup_enabled = _get_env_bool(
"MEMORY_INTENTION_DEDUP_ENABLED", True
)
if self.intention_dedup_threshold is None:
self.intention_dedup_threshold = _get_env_float(
"MEMORY_INTENTION_DEDUP_THRESHOLD",
_get_env_float("MEMORY_DEDUP_THRESHOLD", 0.95),
)
@dataclass
@@ -1384,6 +1401,8 @@ class MemoryConfig:
"supplemental_propositions_enabled": (
self.recall.supplemental_propositions_enabled
),
"intention_dedup_enabled": self.recall.intention_dedup_enabled,
"intention_dedup_threshold": self.recall.intention_dedup_threshold,
},
"pipeline": {
"default_version": self.pipeline.default_version,
+31 -10
View File
@@ -15,6 +15,9 @@ HY Memory - Intention (L7) 召回 + 惰性过期转换 + 复本判定
→ 视为同一个意图的复本(extractor 复读机 / 多 session 重复表达)。
保留组里 `gmt_created` 最早的(老的先来先得,稳定 id);
- **异步删库**:其余复本 fire-and-forget 从 VDB 删除,不影响本次调用 RT。
- **开关**:判重受 `MemoryConfig.recall.intention_dedup_enabled`(默认开)与
`recall.intention_dedup_threshold` 控制,由 reader 按 client 配置传入。
关闭时完全跳过判重:不隐藏、不删除任何意图(写入侧批内判重同样跳过)。
Q: 为什么要看 memory_at 和 valid_until?
A: 意图是 point-in-time 信号。同一句"我周五要开会",如果一条 memory_at 在
@@ -38,8 +41,14 @@ from ...models.memory import MemoryLayer, MemoryStatus
logger = logging.getLogger(__name__)
def _get_dedup_threshold() -> float:
"""去重 cosine 阈值:沿用 dedup 内核的 MEMORY_DEDUP_THRESHOLD,默认 0.95。"""
def _get_dedup_threshold(configured: Optional[float] = None) -> float:
"""去重 cosine 阈值:优先用 client 配置;否则(或配置非法)沿用 dedup 内核的
MEMORY_DEDUP_THRESHOLD,默认 0.95。"""
try:
if configured is not None:
return float(configured)
except (TypeError, ValueError):
logger.warning(f"[intention] invalid dedup threshold: {configured!r}")
try:
return float(os.environ.get("MEMORY_DEDUP_THRESHOLD", 0.95))
except (TypeError, ValueError):
@@ -244,6 +253,8 @@ async def recall_intentions(
stats_out: Optional[Dict[str, Any]] = None,
include_sources: Optional[List[str]] = None,
exclude_sources: Optional[List[str]] = None,
dedup_enabled: bool = True,
dedup_threshold: Optional[float] = None,
) -> List[Dict[str, Any]]:
"""
召回未过期的 L7_INTENTION 节点(VDB),惰性过期转 L2_FACT,判重并异步删复本。
@@ -260,6 +271,11 @@ async def recall_intentions(
now: 当前时间(默认 datetime.now(),测试可注入)
stats_out: 可选 dict,本函数把分段耗时与数量**原地回填**进去,供调用方落
READ_INTENTION pipeline log。传 None 则完全不统计(零开销)。
dedup_enabled: 是否执行读侧判重(及复本异步删库)。False 时跳过判重,
所有未过期意图原样返回,不删除任何节点。默认 True(历史行为)。
dedup_threshold: 判重 cosine 阈值。None 时回退到 env
MEMORY_DEDUP_THRESHOLD(默认 0.95),兼容直接调用本函数的旧代码;
reader 总是传入 `recall.intention_dedup_threshold`。
Returns:
[{"node_id", "score", "node", "layer": "l7_intention", "source": "vdb_intention"}, ...]
@@ -272,7 +288,7 @@ async def recall_intentions(
埋点字段(回填进 stats_out):
耗时(ms): vdb_search_ms / expire_ms / backfill_embed_ms / dedup_ms / total_ms
数量: fetched / expired / survivors / dedup_dropped / returned
配置: fetch_limit / out_limit / dedup_threshold
配置: fetch_limit / out_limit / dedup_enabled / dedup_threshold
说明: expire_ms、backfill_embed_ms 只有真的发生了对应操作才非 0,
借此可判断慢在 VDB 检索、逐条过期转换、补拉向量还是判重计算。
"""
@@ -358,7 +374,7 @@ async def recall_intentions(
_t = time.perf_counter()
_backfilled = 0
_missing_cnt = 0
if survivors:
if survivors and dedup_enabled: # 补拉只为判重;关闭时无需这次 VDB 读
missing_ids = [
h.get("node_id") for h in survivors
if h.get("node_id") and not getattr(h.get("node"), "embedding", None)
@@ -391,18 +407,23 @@ async def recall_intentions(
)
# 2. 判重:同 user + 同天 memory_at + valid_until 同日 + cosine≥threshold
# dedup_enabled=False → 完全跳过:不隐藏、不删除。
_t = time.perf_counter()
threshold = _get_dedup_threshold()
try:
kept, delete_ids = _partition_duplicates(survivors, threshold)
except Exception as e:
# 任何 dedup 异常 → 降级为不去重(返回全部 survivors 截断 limit)
logger.warning(f"[intention] dedup partition failed, degrade to raw: {e}")
threshold = _get_dedup_threshold(dedup_threshold)
if dedup_enabled:
try:
kept, delete_ids = _partition_duplicates(survivors, threshold)
except Exception as e:
# 任何 dedup 异常 → 降级为不去重(返回全部 survivors 截断 limit)
logger.warning(f"[intention] dedup partition failed, degrade to raw: {e}")
kept, delete_ids = survivors, []
else:
kept, delete_ids = survivors, []
# 纯 CPU(cosine 两两比较);survivors 多时可能显著
_stat(
dedup_ms=round((time.perf_counter() - _t) * 1000, 2),
dedup_dropped=len(delete_ids),
dedup_enabled=bool(dedup_enabled),
dedup_threshold=threshold,
)
+6
View File
@@ -1856,6 +1856,12 @@ class BaseReadPipeline(ReadPipeline):
stats_out=_intent_stats,
include_sources=request.include_sources,
exclude_sources=request.exclude_sources,
dedup_enabled=bool(getattr(
self.config.recall, "intention_dedup_enabled", True
)),
dedup_threshold=getattr(
self.config.recall, "intention_dedup_threshold", None
),
)
await trace_log.log_intention(
stats=_intent_stats,
+6 -1
View File
@@ -635,6 +635,7 @@ class MemoryWriter(WritePipeline):
不查 VDB、不做"新写入 vs 库里已有"的对比,避免给 VDB 增加读压力。
库内存量污染由 recall 侧异步删除逐步清理。best-effort:任何异常都
回退到"不判重、全写入",绝不影响 upsert 主流程。
`recall.intention_dedup_enabled=False` 时跳过批内判重,全部写入。
"""
if not intentions:
return []
@@ -660,7 +661,11 @@ class MemoryWriter(WritePipeline):
# ============ 批内 dedup(不查 VDB,只对本批 intentions 互斥判重) ============
keep_mask: List[bool] = [True] * len(intentions)
try:
dropped = self._intention_batch_dedup(intentions, embeddings)
dropped = (
self._intention_batch_dedup(intentions, embeddings)
if getattr(self.config.recall, "intention_dedup_enabled", True)
else []
)
for i in dropped:
if 0 <= i < len(keep_mask):
keep_mask[i] = False
@@ -9,7 +9,7 @@ import type {
Tray,
} from "electron";
import { describe, expect, it, vi } from "vitest";
import { installBackgroundTray, type RuntimeHosting } from "./background-tray";
import { installBackgroundTray } from "./background-tray";
import type { LoginPreference } from "./login-preference";
async function flush() {
@@ -27,7 +27,6 @@ function fixture(
status: "disabled",
};
let changed: (() => void) | undefined;
let hosting: RuntimeHosting | Error = "background-service";
const languageSnapshot: LanguageSnapshot = {
preference: "zh-Hans",
locale: "zh-Hans",
@@ -51,10 +50,6 @@ function fixture(
};
return state;
});
const readRuntimeHosting = vi.fn(async () => {
if (hosting instanceof Error) throw hosting;
return hosting;
});
const open = vi.fn();
const reportError = vi.fn();
const install = () =>
@@ -82,7 +77,6 @@ function fixture(
reportError,
readLoginPreference,
setLoginPreference,
readRuntimeHosting,
...(trial
? { allowLoginStartup: false, productName: "One Memory Trial" }
: {}),
@@ -97,7 +91,6 @@ function fixture(
menus,
readLoginPreference,
setLoginPreference,
readRuntimeHosting,
open,
reportError,
install,
@@ -110,9 +103,6 @@ function fixture(
setState(value: LoginPreference) {
state = value;
},
setHosting(value: RuntimeHosting | Error) {
hosting = value;
},
};
}
@@ -189,50 +179,6 @@ describe("native macOS background tray", () => {
expect(f.tray.eventNames()).toEqual([]);
});
it("says quitting stops the memory service when the App hosts it", async () => {
const f = fixture();
f.setHosting("app-hosted");
f.install();
await flush();
expect(f.menu()[3]?.label).toBe("退出 MemHub(同时停止记忆服务)");
f.changeLanguage("es");
await flush();
expect(f.menu()[3]?.label).toBe(
"Salir de MemHub (también detiene el servicio de memoria)",
);
});
it("re-reads who hosts the Runtime every time the menu opens", async () => {
const f = fixture();
f.install();
await flush();
expect(f.menu()[3]?.label).toContain("后台服务继续运行");
// The user turned the background service off in Settings.
f.setHosting("app-hosted");
f.tray.emit("mouse-down");
await flush();
expect(f.menu()[3]?.label).toContain("同时停止记忆服务");
f.setHosting("background-service");
f.tray.emit("right-click");
await flush();
expect(f.menu()[3]?.label).toContain("后台服务继续运行");
expect(f.readRuntimeHosting).toHaveBeenCalledTimes(3);
});
it("never claims quit stops the service when the state is unknown", async () => {
const f = fixture();
f.setHosting(new Error("launchctl timed out"));
f.install();
await flush();
expect(f.menu()[3]?.label).toContain("后台服务继续运行");
f.setHosting("unknown");
f.tray.emit("mouse-down");
await flush();
expect(f.menu()[3]?.label).toContain("后台服务继续运行");
});
it("blocks login changes for trial, development and non-macOS builds", async () => {
for (const f of [
fixture(true, "darwin", true),
+1 -16
View File
@@ -10,8 +10,6 @@ import type {
import type { LoginPreference } from "./login-preference";
import type { NativeLanguageSource } from "./native-language";
export type RuntimeHosting = "background-service" | "app-hosted" | "unknown";
export const LOGIN_SERVICE_NAME = "com.tencent.onememory.runtime.plist";
interface TrayOptions {
@@ -26,9 +24,6 @@ interface TrayOptions {
reportError(message: string): void;
readLoginPreference(): Promise<LoginPreference>;
setLoginPreference(enabled: boolean): Promise<LoginPreference>;
/** Who keeps the Runtime alive, re-read on every menu refresh: the
* background service (quit leaves it running) or this App (quit stops it). */
readRuntimeHosting?(): Promise<RuntimeHosting>;
allowLoginStartup?: boolean;
productName?: string;
language?: NativeLanguageSource;
@@ -72,13 +67,6 @@ export function installBackgroundTray(options: TrayOptions): () => void {
} else {
label += t("nativeDevelopmentUnavailable");
}
let hosting: RuntimeHosting = "unknown";
try {
hosting = (await options.readRuntimeHosting?.()) ?? "unknown";
} catch {
hosting = "unknown";
}
if (disposed || current !== generation) return;
tray.setContextMenu(
options.buildMenu([
{ label: t("nativeOpen"), click: options.open },
@@ -104,10 +92,7 @@ export function installBackgroundTray(options: TrayOptions): () => void {
},
{ type: "separator" },
{
// Unknown keeps the plain label: quit then stops nothing.
label: t(
hosting === "app-hosted" ? "nativeQuitStopsService" : "nativeQuit",
),
label: t("nativeQuit"),
click: () => {
if (!disposed) app.quit();
},
@@ -5,7 +5,6 @@ import {
claimRuntimeServiceForApp,
claimRuntimeServiceForCli,
enableRuntimeService,
type RuntimeServiceState,
runtimeServiceEnableMarkerPath,
} from "@one-memory/runtime-control";
import { expect, it } from "vitest";
@@ -99,25 +98,3 @@ it("rejects unsupported and unconfirmed writes", async () => {
),
).rejects.toThrow("login_preference_unsupported");
});
it("reads a launchctl-disabled service as off and not switchable, asked fresh", async () => {
const dataDir = await appOwned();
let state: RuntimeServiceState = {
state: "disabled",
reason: "service-disabled-by-launchctl",
jobPid: null,
};
const serviceState = async () => state;
await expect(
readLoginPreference(dataDir, "darwin", true, serviceState),
).resolves.toEqual({
supported: false,
enabled: false,
status: "disabled-by-system",
});
state = { state: "enabled", reason: "service-enabled", jobPid: 4321 };
await expect(
readLoginPreference(dataDir, "darwin", true, serviceState),
).resolves.toEqual({ supported: true, enabled: true, status: "enabled" });
});
+1 -15
View File
@@ -1,7 +1,6 @@
import {
disableRuntimeService,
enableRuntimeService,
type RuntimeServiceState,
readRuntimeServiceOwner,
runtimeServiceEnabled,
setAppRuntimeStartAtLogin,
@@ -13,16 +12,10 @@ export interface LoginPreference {
status: string;
}
/** "Launch at login" is the background service switch: it writes the enable
* marker the LaunchAgent's `KeepAlive` waits on. On it means the Runtime
* outlives the App and quitting leaves it running; off means quitting stops
* it. `serviceState`, when given, is asked fresh so a `launchctl disable`
* override (which the switch cannot undo) reads as off, not as on. */
export async function readLoginPreference(
dataDir: string,
platform: NodeJS.Platform,
supported: boolean,
serviceState?: () => Promise<RuntimeServiceState>,
): Promise<LoginPreference> {
if (platform !== "darwin" || !supported) {
return { supported: false, enabled: false, status: "unsupported" };
@@ -35,12 +28,6 @@ export async function readLoginPreference(
if (owner?.owner !== "app") {
return { supported: false, enabled: false, status: "not-installed" };
}
if (
serviceState &&
(await serviceState()).reason === "service-disabled-by-launchctl"
) {
return { supported: false, enabled: false, status: "disabled-by-system" };
}
return {
supported: true,
enabled: owner.start_at_login && (await runtimeServiceEnabled(dataDir)),
@@ -59,7 +46,6 @@ export async function setLoginPreference(
platform: NodeJS.Platform,
input: unknown,
supported: boolean,
serviceState?: () => Promise<RuntimeServiceState>,
): Promise<LoginPreference> {
if (
!input ||
@@ -77,5 +63,5 @@ export async function setLoginPreference(
await setAppRuntimeStartAtLogin(dataDir, input.openAtLogin);
if (input.openAtLogin) await enableRuntimeService(dataDir);
else await disableRuntimeService(dataDir);
return readLoginPreference(dataDir, platform, supported, serviceState);
return readLoginPreference(dataDir, platform, supported);
}
+17 -130
View File
@@ -23,9 +23,7 @@ import {
type HostPermissionClient,
installPackagedRuntime,
installUserService,
queryRuntimeServiceState,
RecallBrokerSupervisor,
type RuntimeServiceState,
RuntimeSupervisor,
readInstalledRuntimeSource,
readPublishedCorePort,
@@ -112,7 +110,6 @@ import { showMigrationFailureDialog } from "./migration-failure-dialog";
import { isAllowedNavigation } from "./navigation-policy";
import { installDenyAllPermissionPolicy } from "./permissions";
import { isHostPresent, recallHosts } from "./recall-hosts";
import { reconcileRunningRuntime } from "./runtime-adoption";
import { shutdownRuntime } from "./runtime-shutdown";
import { createSourceChangeHandler } from "./source-change-handler";
import { SourceDirectoryPicker } from "./source-directory-picker";
@@ -143,12 +140,7 @@ let runtimeSupervisor: RuntimeSupervisor | null = null;
// Where the Runtime keeps its receipt. Stopping it on a full quit needs this,
// and `stop` runs outside `initialize`, where the directory is chosen.
let runtimeDataDir: string | null = null;
// Whether quitting stops the Runtime is asked of the service manager at quit
// time (`currentRuntimeServiceState`), never remembered from startup. The one
// startup fact kept is a Runtime whose build could not be read: this App never
// stops one it could not compare with its own.
let preserveRuntimeOnQuitReason: string | null = null;
let runtimeServiceManagedByApp = false;
let preserveManagedRuntimeOnQuit = false;
let disposeTray: (() => void) | null = null;
let disposeApplicationMenu: (() => void) | null = null;
let languagePreferences: LanguagePreferences | null = null;
@@ -255,25 +247,6 @@ if (trialProfile) {
app.setPath("userData", hourlyUserDataDir);
}
/** Ask the service manager, now, whether the background service for this data
* dir is on. Never cached: quit and the tray each re-read it, so a switch made
* in Settings, by `memhub service`, or by `launchctl` counts immediately. */
function currentRuntimeServiceState(): Promise<RuntimeServiceState> {
if (!runtimeDataDir) {
return Promise.resolve({
state: "unknown",
reason: "service-state-query-failed",
jobPid: null,
});
}
return queryRuntimeServiceState({
dataDir: runtimeDataDir,
home: app.getPath("home"),
platform: process.platform,
uid: process.getuid?.(),
});
}
function prepareProtocol(): void {
protocol.registerSchemesAsPrivileged([
{
@@ -525,7 +498,6 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
!preview &&
(hostIntegrationEnabled || desktopTestIsolation !== null) &&
installedApplication;
runtimeServiceManagedByApp = appManagedRuntime;
const runningPackagedHost =
app.isPackaged && !isolatedHourly
? await resolveRunningPackagedHostPaths({
@@ -879,25 +851,14 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
);
ipcMain.handle("one-memory:get-login-preference", (event) => {
assertAllowedRenderer(event.senderFrame?.url);
return readLoginPreference(
dataDir,
process.platform,
appManagedRuntime,
currentRuntimeServiceState,
);
return readLoginPreference(dataDir, process.platform, appManagedRuntime);
});
ipcMain.handle("one-memory:set-login-preference", (event, input: unknown) => {
assertAllowedRenderer(event.senderFrame?.url);
return observeDesktopAction(
"login_preference",
() =>
setLoginPreference(
dataDir,
process.platform,
input,
appManagedRuntime,
currentRuntimeServiceState,
),
setLoginPreference(dataDir, process.platform, input, appManagedRuntime),
{ action: "set" },
);
});
@@ -1208,6 +1169,7 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
const startAtLogin = owner?.owner === "app" ? owner.start_at_login : true;
const previousOwner = owner;
let runtimeChanged = false;
let transientRuntime = false;
// Install whoever owns the service. Owner decides who starts and stops
// it, not which build runs: skipping the install for a CLI owner (also
// the App's own guess when its receipt is missing) left an upgraded App
@@ -1277,68 +1239,12 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
const frozen = await readSdkMigrationBlock(dataDir);
if (frozen?.frozen !== undefined) throw new Error(frozen.code);
}
// One Runtime per data dir. A Runtime already serving it is adopted
// when it is this App's build or newer (silently; a newer one is never
// stopped or downgraded at startup); an older one is replaced (through
// launchd when it is the service's job), and one whose build cannot be
// read is adopted but never stopped.
const reconciled = await reconcileRunningRuntime({
app: {
...(packagedBuildNumber === undefined
? {}
: { buildNumber: packagedBuildNumber }),
...(packagedCommit === undefined ? {} : { commit: packagedCommit }),
},
installedChanged: runtimeChanged,
findRunning: () =>
waitForRuntimeProcess({ dataDir, attempts: 4 }).then(
(attachment) =>
attachment.servicePid === null
? null
: {
pid: attachment.servicePid,
build: attachment.runtimeBuild,
},
() => null,
),
queryServiceState: currentRuntimeServiceState,
stopRuntime: () => stopRuntimeProcess({ dataDir }),
log: ({ decision, service, running }) =>
diagnostics.log({
level: decision.action === "adopt-unverified" ? "warn" : "info",
component: "desktop.stable_runtime",
event: "stable_runtime.adoption",
outcome:
decision.action === "adopt-unverified" ? "degraded" : "succeeded",
details: {
action: decision.action,
reason:
"reason" in decision
? decision.reason
: (service?.reason ?? "no-runtime-running"),
status: service?.state ?? "not-queried",
version:
running === null
? "none"
: running.build === undefined
? "legacy"
: String(running.build.buildNumber ?? "unknown"),
},
}),
});
preserveRuntimeOnQuitReason = reconciled.preserveReason;
if (reconciled.decision.action === "adopt-unverified") {
void dialog
.showMessageBox({
type: "warning",
message: "MemHub",
detail: stableRuntimeFailureDetail(
new Error("runtime_build_unknown"),
),
})
.catch(() => undefined);
}
const alreadyRunning = reconciled.adopted;
const alreadyRunning = runtimeChanged
? false
: await waitForRuntimeProcess({ dataDir, attempts: 4 }).then(
() => true,
() => false,
);
if (owner?.owner !== "cli") {
const appBundle = path.resolve(path.dirname(process.execPath), "../..");
owner = await claimRuntimeServiceForApp({
@@ -1352,13 +1258,8 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
// A durable `launchctl disable` override is a state to report, not a
// failure to undo: `installUserService` never re-enables a service the
// user or the system disabled. An explicit App launch may still own a
// transient Runtime for this UI session; quit stops it because the
// service is disabled, which it decides by asking launchd again then.
// For an enabled service, `installUserService` restarts the job with
// `kickstart -k` (or re-bootstraps a changed plist), so an older
// Runtime is replaced through launchd rather than killed into a
// respawn of the old binary.
await installServiceWithDisabledFallback({
// transient Runtime for this UI session. Shutdown stops that process.
const serviceMode = await installServiceWithDisabledFallback({
install: async () => {
await installUserService(layout, {
uid: process.getuid?.(),
@@ -1390,10 +1291,12 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
});
},
});
transientRuntime = serviceMode === "transient";
}
if (owner?.owner === "app" && !startAtLogin) {
await disableRuntimeService(dataDir);
}
preserveManagedRuntimeOnQuit = !transientRuntime;
} else if (standaloneRuntime) {
if (
!desktopBackgroundLaunch &&
@@ -1762,8 +1665,7 @@ if (desktopVersion !== null) {
? readLoginPreference(
runtimeDataDir,
process.platform,
runtimeServiceManagedByApp,
currentRuntimeServiceState,
preserveManagedRuntimeOnQuit,
)
: Promise.resolve({
supported: false,
@@ -1776,21 +1678,9 @@ if (desktopVersion !== null) {
runtimeDataDir,
process.platform,
{ openAtLogin: enabled, userConfirmed: true },
runtimeServiceManagedByApp,
currentRuntimeServiceState,
preserveManagedRuntimeOnQuit,
);
},
readRuntimeHosting: async () => {
if (!runtimeDataDir || !app.isPackaged) return "unknown";
// A Runtime whose build could not be read is never stopped by it.
if (preserveRuntimeOnQuitReason) return "unknown";
const service = await currentRuntimeServiceState();
return service.state === "enabled"
? "background-service"
: service.state === "disabled"
? "app-hosted"
: "unknown";
},
...(trialProfile
? { allowLoginStartup: false, productName: app.getName() }
: {}),
@@ -1822,10 +1712,7 @@ if (desktopVersion !== null) {
// stop and says so by taking the embedded path instead.
dataDir: runtimeDataDir ?? "",
standalone: app.isPackaged && runtimeDataDir !== null,
// Asked now, not remembered from startup: the user may have switched
// the background service on or off since.
queryServiceState: currentRuntimeServiceState,
preserveReason: preserveRuntimeOnQuitReason,
preserveManagedRuntime: preserveManagedRuntimeOnQuit,
stopSupervisor: async () => {
const results = await Promise.allSettled([
runtimeSupervisor?.stop() ?? coreSupervisor?.stop(),
@@ -1,233 +0,0 @@
import type { RuntimeServiceState } from "@one-memory/runtime-control";
import { describe, expect, it, vi } from "vitest";
import {
compareRuntimeBuild,
decideRuntimeAdoption,
type RunningRuntime,
reconcileRunningRuntime,
} from "./runtime-adoption";
const app = { buildNumber: 38, commit: "abcdef1234567" };
const enabled = (jobPid: number | null): RuntimeServiceState => ({
state: "enabled",
reason: "service-enabled",
jobPid,
});
const disabled: RuntimeServiceState = {
state: "disabled",
reason: "service-disabled-by-launchctl",
jobPid: null,
};
const unknown: RuntimeServiceState = {
state: "unknown",
reason: "service-state-timeout",
jobPid: null,
};
const running = (
build: RunningRuntime["build"],
pid = 4321,
): RunningRuntime => ({ pid, build });
function reconcile(options: {
running: RunningRuntime | null;
service: RuntimeServiceState;
installedChanged?: boolean;
}) {
const stopRuntime = vi.fn(async () => "stopped");
const queryServiceState = vi.fn(async () => options.service);
const result = reconcileRunningRuntime({
app,
installedChanged: options.installedChanged ?? false,
findRunning: async () => options.running,
queryServiceState,
stopRuntime,
});
return { result, stopRuntime, queryServiceState };
}
describe("comparing the running Runtime's build with the App's", () => {
it("compares build numbers, then commits both sides know", () => {
expect(compareRuntimeBuild(app, { buildNumber: 38, commit: null })).toBe(
"same",
);
expect(
compareRuntimeBuild(app, { buildNumber: 38, commit: "abcdef1" }),
).toBe("same");
expect(
compareRuntimeBuild(app, { buildNumber: 38, commit: "0123456" }),
).toBe("app-newer");
expect(compareRuntimeBuild(app, { buildNumber: 37, commit: null })).toBe(
"app-newer",
);
expect(compareRuntimeBuild(app, { buildNumber: 39, commit: null })).toBe(
"runtime-newer",
);
});
it("reads a receipt without build fields as a Runtime older than this App", () => {
expect(compareRuntimeBuild(app, undefined)).toBe("app-newer");
});
it("is unknown when either side cannot say", () => {
expect(compareRuntimeBuild(app, { buildNumber: null, commit: null })).toBe(
"unknown",
);
expect(compareRuntimeBuild({}, { buildNumber: 38, commit: null })).toBe(
"unknown",
);
});
});
describe("App start adopts or replaces a running Runtime", () => {
it("starts one when nothing runs, without asking the service manager", async () => {
const { result, stopRuntime, queryServiceState } = reconcile({
running: null,
service: enabled(null),
});
await expect(result).resolves.toMatchObject({
decision: { action: "start" },
adopted: false,
});
expect(queryServiceState).not.toHaveBeenCalled();
expect(stopRuntime).not.toHaveBeenCalled();
});
it("adopts the same build instead of starting a second Runtime", async () => {
const { result, stopRuntime } = reconcile({
running: running({ buildNumber: 38, commit: null }),
service: disabled,
});
await expect(result).resolves.toEqual({
decision: { action: "adopt" },
adopted: true,
preserveReason: null,
});
expect(stopRuntime).not.toHaveBeenCalled();
});
it("adopts the service's own job when the service is enabled", async () => {
const { result, stopRuntime } = reconcile({
running: running({ buildNumber: 38, commit: null }, 4321),
service: enabled(4321),
});
await expect(result).resolves.toMatchObject({
decision: { action: "adopt" },
adopted: true,
});
expect(stopRuntime).not.toHaveBeenCalled();
});
it("ends with one Runtime when a stray holds the lease beside an enabled service", async () => {
const { result, stopRuntime } = reconcile({
running: running({ buildNumber: 38, commit: null }, 777),
service: enabled(null),
});
await expect(result).resolves.toMatchObject({
decision: { action: "stop-then-start", reason: "stray-runtime" },
adopted: false,
});
expect(stopRuntime).toHaveBeenCalledOnce();
});
it("does not treat a Runtime as a stray when the service state is unknown", async () => {
const { result, stopRuntime } = reconcile({
running: running({ buildNumber: 38, commit: null }, 777),
service: unknown,
});
await expect(result).resolves.toMatchObject({
decision: { action: "adopt" },
});
expect(stopRuntime).not.toHaveBeenCalled();
});
it("replaces an older Runtime that is the service's job through the service manager", async () => {
const { result, stopRuntime } = reconcile({
running: running({ buildNumber: 37, commit: null }, 4321),
service: enabled(4321),
});
await expect(result).resolves.toMatchObject({
decision: { action: "replace-via-service" },
adopted: false,
preserveReason: null,
});
// No plain kill: launchd would answer it with a respawn of the old job.
expect(stopRuntime).not.toHaveBeenCalled();
});
it("replaces a legacy service job whose receipt has no build through launchd", async () => {
const { result, stopRuntime } = reconcile({
running: running(undefined, 4321),
service: enabled(4321),
});
await expect(result).resolves.toMatchObject({
decision: { action: "replace-via-service" },
});
expect(stopRuntime).not.toHaveBeenCalled();
});
it("stops an older transient or CLI-started Runtime, then starts the right one", async () => {
const { result, stopRuntime } = reconcile({
running: running({ buildNumber: 36, commit: null }, 999),
service: disabled,
});
await expect(result).resolves.toMatchObject({
decision: { action: "stop-then-start", reason: "app-newer" },
adopted: false,
});
expect(stopRuntime).toHaveBeenCalledOnce();
});
it("replaces a same-build Runtime when this launch moved `current`", () => {
expect(
decideRuntimeAdoption({
running: running({ buildNumber: 38, commit: null }, 4321),
app,
service: enabled(4321),
installedChanged: true,
}),
).toEqual({ action: "replace-via-service" });
});
it("adopts a newer Runtime silently and leaves quit to the service state", async () => {
const { result, stopRuntime } = reconcile({
running: running({ buildNumber: 40, commit: null }, 999),
service: disabled,
});
// No special quit pass: with the service disabled, quit stops it like any
// adopted Runtime; with it enabled, quit preserves it.
await expect(result).resolves.toEqual({
decision: { action: "adopt-newer" },
adopted: true,
preserveReason: null,
});
expect(stopRuntime).not.toHaveBeenCalled();
});
it("never stops or downgrades a newer Runtime at startup, even beside an enabled service", async () => {
const { result, stopRuntime } = reconcile({
running: running({ buildNumber: 40, commit: null }, 999),
service: enabled(4321),
installedChanged: true,
});
await expect(result).resolves.toMatchObject({
decision: { action: "adopt-newer" },
adopted: true,
});
expect(stopRuntime).not.toHaveBeenCalled();
});
it("never kills a Runtime whose build cannot be read", async () => {
const { result, stopRuntime } = reconcile({
running: running({ buildNumber: null, commit: null }, 999),
service: disabled,
installedChanged: true,
});
await expect(result).resolves.toEqual({
decision: { action: "adopt-unverified" },
adopted: true,
preserveReason: "runtime-build-unknown",
});
expect(stopRuntime).not.toHaveBeenCalled();
});
});
-158
View File
@@ -1,158 +0,0 @@
import type {
RuntimeReceiptBuild,
RuntimeServiceState,
} from "@one-memory/runtime-control";
/** The build this App carries. */
export interface AppBuild {
buildNumber?: number;
commit?: string;
}
/** The Runtime found serving the data dir when the App starts. */
export interface RunningRuntime {
pid: number;
/** As published in its receipt; `undefined` for a receipt without the
* fields, which only a Runtime older than this App writes. */
build: RuntimeReceiptBuild | undefined;
}
export type RuntimeVersionRelation =
| "same"
| "app-newer"
| "runtime-newer"
| "unknown";
export type RuntimeAdoption =
/** Nothing is running: install or start this App's Runtime. */
| { action: "start" }
/** Same build: use it as it is. */
| { action: "adopt" }
/** Its build cannot be compared: use it, never stop it, tell the user. */
| { action: "adopt-unverified" }
/** Older, and it is the service manager's job: replace it through the
* service manager, never with a plain kill launchd would answer with a
* respawn of whatever it last loaded. */
| { action: "replace-via-service" }
/** Older and transient/CLI-started, or a stray beside an enabled service:
* stop it gracefully, then install or start the right one. */
| { action: "stop-then-start"; reason: "app-newer" | "stray-runtime" }
/** Newer than this App: use it as it is, silently. Never stopped or
* downgraded at startup and never joined by a second Runtime; quit treats
* it like any adopted Runtime. */
| { action: "adopt-newer" };
/** Compare by build number, then by commit when both sides know theirs. */
export function compareRuntimeBuild(
app: AppBuild,
runtime: RuntimeReceiptBuild | undefined,
): RuntimeVersionRelation {
if (app.buildNumber === undefined) return "unknown";
// Every Runtime that publishes its build carries the fields, even as null.
// A receipt without them predates that contract, and so this App.
if (runtime === undefined) return "app-newer";
if (runtime.buildNumber === null) return "unknown";
if (runtime.buildNumber < app.buildNumber) return "app-newer";
if (runtime.buildNumber > app.buildNumber) return "runtime-newer";
const appCommit = app.commit?.trim().toLowerCase();
if (
appCommit &&
runtime.commit &&
!appCommit.startsWith(runtime.commit) &&
!runtime.commit.startsWith(appCommit)
) {
// Same build, different commit: the App's bundle takes over, exactly as
// `installPackagedRuntime({ replaceSameBuild: true })` already does.
return "app-newer";
}
return "same";
}
export function decideRuntimeAdoption(input: {
running: RunningRuntime | null;
app: AppBuild;
service: RuntimeServiceState;
/** This launch just moved `current` to the App's bundled Runtime, so the
* process still running was started from the install it replaced. */
installedChanged?: boolean;
}): RuntimeAdoption {
const { running, service } = input;
if (running === null) return { action: "start" };
let relation = compareRuntimeBuild(input.app, running.build);
if (relation === "same" && input.installedChanged) relation = "app-newer";
if (relation === "runtime-newer") return { action: "adopt-newer" };
if (relation === "unknown") return { action: "adopt-unverified" };
const serviceJob = service.jobPid !== null && service.jobPid === running.pid;
if (relation === "app-newer") {
return serviceJob
? { action: "replace-via-service" }
: { action: "stop-then-start", reason: "app-newer" };
}
// One Runtime per data dir: with the service on, the one that stays is the
// service's own job, not a transient or CLI process holding its lease.
if (service.state === "enabled" && !serviceJob) {
return { action: "stop-then-start", reason: "stray-runtime" };
}
return { action: "adopt" };
}
export interface RuntimeReconcileDependencies {
app: AppBuild;
installedChanged?: boolean;
findRunning(): Promise<RunningRuntime | null>;
queryServiceState(): Promise<RuntimeServiceState>;
/** Graceful stop of the one verified Runtime: in-flight writes finish. */
stopRuntime(): Promise<unknown>;
log?(event: {
decision: RuntimeAdoption;
service: RuntimeServiceState | null;
running: RunningRuntime | null;
}): Promise<void> | void;
}
export interface RuntimeReconcileResult {
decision: RuntimeAdoption;
/** The running Runtime stays and the caller must not start another. */
adopted: boolean;
/** Quit must leave the adopted Runtime alone whatever the service state:
* this App could not prove it is its own build. */
preserveReason: string | null;
}
/** Decide what to do with a Runtime already serving the data dir at App start,
* and do the part that has to happen before the normal start path runs. */
export async function reconcileRunningRuntime(
dependencies: RuntimeReconcileDependencies,
): Promise<RuntimeReconcileResult> {
const running = await dependencies.findRunning();
const service =
running === null ? null : await dependencies.queryServiceState();
const decision = decideRuntimeAdoption({
running,
app: dependencies.app,
service: service ?? {
state: "unknown",
reason: "service-state-query-failed",
jobPid: null,
},
installedChanged: dependencies.installedChanged ?? false,
});
await dependencies.log?.({ decision, service, running });
switch (decision.action) {
case "start":
case "replace-via-service":
return { decision, adopted: false, preserveReason: null };
case "stop-then-start":
await dependencies.stopRuntime();
return { decision, adopted: false, preserveReason: null };
case "adopt":
case "adopt-newer":
return { decision, adopted: true, preserveReason: null };
case "adopt-unverified":
return {
decision,
adopted: true,
preserveReason: "runtime-build-unknown",
};
}
}
+32 -174
View File
@@ -1,162 +1,96 @@
import type {
RuntimeServiceState,
StopAllRuntimesResult,
} from "@one-memory/runtime-control";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { shutdownRuntime } from "./runtime-shutdown";
const { stopAllRuntimeProcesses } = vi.hoisted(() => ({
stopAllRuntimeProcesses: vi.fn<
(options: { dataDir: string }) => Promise<StopAllRuntimesResult>
>(async () => ({ stopped: [4321], unverified: [] })),
const { stopRuntimeProcess } = vi.hoisted(() => ({
stopRuntimeProcess: vi.fn<() => Promise<"stopped" | "not-running">>(
async () => "stopped",
),
}));
vi.mock("@one-memory/runtime-control", () => ({ stopAllRuntimeProcesses }));
vi.mock("@one-memory/runtime-control", () => ({ stopRuntimeProcess }));
const stopMock = vi.mocked(stopAllRuntimeProcesses);
const enabled: RuntimeServiceState = {
state: "enabled",
reason: "service-enabled",
jobPid: 4321,
};
const disabled = (
reason: RuntimeServiceState["reason"] = "service-enable-marker-absent",
): RuntimeServiceState => ({ state: "disabled", reason, jobPid: null });
const unknown: RuntimeServiceState = {
state: "unknown",
reason: "service-state-timeout",
jobPid: null,
};
const stopMock = vi.mocked(stopRuntimeProcess);
beforeEach(() => {
stopMock.mockReset();
stopMock.mockResolvedValue({ stopped: [4321], unverified: [] });
stopMock.mockClear();
stopMock.mockResolvedValue("stopped");
});
describe("App quit with the background service enabled", () => {
it("never stops the Runtime", async () => {
describe("BUG-12: a full quit stops the standalone Runtime process", () => {
it("detaches the UI without stopping a managed Runtime service", async () => {
const stopSupervisor = vi.fn(async () => {});
const events: string[] = [];
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: true,
queryServiceState: async () => enabled,
preserveManagedRuntime: true,
stopSupervisor,
onDiagnostic: (event) => {
events.push(event.detail);
},
});
expect(result).toEqual({
runtime: "preserved",
reason: "background-service-enabled",
});
expect(result).toEqual({ runtime: "unchanged" });
expect(stopSupervisor).toHaveBeenCalledOnce();
expect(stopMock).not.toHaveBeenCalled();
expect(events).toEqual(["runtime-preserved:background-service-enabled"]);
expect(events).toEqual(["managed-runtime-preserved"]);
});
it("does not stop a transient Runtime this App started before the service was enabled", async () => {
// Startup fell back to a transient child (service launchctl-disabled) and
// the user re-enabled the service later. Nothing startup saw is an input
// here: quit asks the service manager again and preserves the Runtime.
const queryServiceState = vi.fn(async () => enabled);
it("stops the Runtime process in a packaged build", async () => {
const stopSupervisor = vi.fn(async () => {});
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: true,
queryServiceState,
stopSupervisor: async () => {},
});
expect(queryServiceState).toHaveBeenCalledOnce();
expect(result.runtime).toBe("preserved");
expect(stopMock).not.toHaveBeenCalled();
});
});
describe("App quit with the background service disabled", () => {
it("stops every Runtime of the data dir: the App's child and an external one", async () => {
stopMock.mockResolvedValue({ stopped: [1111, 2222], unverified: [] });
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: true,
queryServiceState: async () => disabled(),
stopSupervisor: async () => {},
stopSupervisor,
});
expect(result).toEqual({ runtime: "stopped" });
expect(stopMock).toHaveBeenCalledWith({ dataDir: "/tmp/runtime-data" });
expect(stopSupervisor).toHaveBeenCalledOnce();
});
it.each([
"service-not-installed",
"service-disabled-by-launchctl",
"service-enable-marker-absent",
] as const)("treats %s as disabled", async (reason) => {
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: true,
queryServiceState: async () => disabled(reason),
});
expect(result).toEqual({ runtime: "stopped" });
expect(stopMock).toHaveBeenCalledOnce();
});
it("reports a live lease owner it could not verify instead of signalling it", async () => {
stopMock.mockResolvedValue({ stopped: [], unverified: [9999] });
const events: string[] = [];
it("does not touch a separate Runtime process in an embedded build", async () => {
const stopSupervisor = vi.fn(async () => {});
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: true,
queryServiceState: async () => disabled(),
onDiagnostic: (event) => {
events.push(`${event.outcome}:${event.detail}`);
},
standalone: false,
stopSupervisor,
});
expect(result).toEqual({
runtime: "failed",
detail: "unverified-runtime-left-running",
});
expect(events).toEqual(["degraded:unverified-runtime-left-running"]);
expect(result).toEqual({ runtime: "unchanged" });
expect(stopMock).not.toHaveBeenCalled();
expect(stopSupervisor).toHaveBeenCalledOnce();
});
it("tears the supervisor down before it stops the process", async () => {
const order: string[] = [];
stopMock.mockImplementation(async () => {
order.push("process");
return { stopped: [4321], unverified: [] };
return "stopped";
});
await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: true,
queryServiceState: async () => {
order.push("query");
return disabled();
},
stopSupervisor: async () => {
order.push("supervisor");
},
});
expect(order).toEqual(["supervisor", "query", "process"]);
expect(order).toEqual(["supervisor", "process"]);
});
it("reports not-running when nothing serves the data dir", async () => {
stopMock.mockResolvedValue({ stopped: [], unverified: [] });
it("reports not-running instead of throwing when nothing is attached", async () => {
stopMock.mockResolvedValue("not-running");
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: true,
queryServiceState: async () => disabled(),
stopSupervisor: async () => {},
});
expect(result).toEqual({ runtime: "not-running" });
@@ -169,7 +103,7 @@ describe("App quit with the background service disabled", () => {
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: true,
queryServiceState: async () => disabled(),
stopSupervisor: async () => {},
onDiagnostic: async (event) => {
events.push(event.outcome);
},
@@ -183,100 +117,24 @@ describe("App quit with the background service disabled", () => {
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: true,
queryServiceState: async () => disabled(),
stopSupervisor: async () => {
throw new Error("supervisor exploded");
},
});
// The process is what must not be left behind; a supervisor error is not
// an excuse to skip it.
expect(stopMock).toHaveBeenCalledOnce();
expect(result).toEqual({
runtime: "failed",
detail: "supervisor exploded",
});
});
});
describe("App quit when the service state cannot be determined", () => {
it("stops nothing on a timeout and says so", async () => {
const events: string[] = [];
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: true,
queryServiceState: async () => unknown,
onDiagnostic: (event) => {
events.push(`${event.outcome}:${event.detail}`);
},
});
expect(result).toEqual({
runtime: "preserved",
reason: "service-state-unknown:service-state-timeout",
});
expect(stopMock).not.toHaveBeenCalled();
expect(events).toEqual([
"degraded:runtime-preserved:service-state-unknown:service-state-timeout",
]);
});
it("stops nothing when the query itself throws", async () => {
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: true,
queryServiceState: async () => {
throw new Error("launchctl exploded");
},
});
expect(result).toEqual({
runtime: "preserved",
reason: "service-state-unknown:service-state-query-failed",
});
expect(stopMock).not.toHaveBeenCalled();
});
});
describe("Runtimes quit never touches", () => {
it("leaves a Runtime whose build could not be read, even with the service disabled", async () => {
const queryServiceState = vi.fn(async () => disabled());
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: true,
queryServiceState,
preserveReason: "runtime-build-unknown",
});
expect(result).toEqual({
runtime: "preserved",
reason: "runtime-build-unknown",
});
expect(stopMock).not.toHaveBeenCalled();
});
it("does not touch a separate Runtime process in an embedded build", async () => {
const stopSupervisor = vi.fn(async () => {});
const queryServiceState = vi.fn(async () => disabled());
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: false,
queryServiceState,
stopSupervisor,
});
expect(result).toEqual({ runtime: "unchanged" });
expect(stopMock).not.toHaveBeenCalled();
expect(queryServiceState).not.toHaveBeenCalled();
expect(stopSupervisor).toHaveBeenCalledOnce();
});
it("reports a supervisor failure in an embedded build too", async () => {
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: false,
queryServiceState: async () => disabled(),
stopSupervisor: async () => {
throw new Error("supervisor exploded");
},
+23 -67
View File
@@ -1,24 +1,13 @@
import {
type RuntimeServiceState,
type StopAllRuntimesResult,
stopAllRuntimeProcesses,
} from "@one-memory/runtime-control";
import { stopRuntimeProcess } from "@one-memory/runtime-control";
export interface RuntimeShutdownOptions {
dataDir: string;
/** Packaged builds run the Runtime as their own process; a dev build's Core
* is this process's child and is stopped by the supervisor. */
standalone: boolean;
/** Asks the service manager whether the background service is on. Called
* at quit time, every time: what startup saw may no longer be true. */
queryServiceState: () => Promise<RuntimeServiceState>;
/** Set when startup adopted a Runtime whose build it could not read. This
* App never stops such a Runtime. A newer Runtime gets no such pass: it
* follows the service state like any adopted Runtime. */
preserveReason?: string | null;
stopRuntimes?: (options: {
dataDir: string;
}) => Promise<StopAllRuntimesResult>;
/** A managed OS service outlives Electron; a transient App-spawned Runtime
* is still stopped on quit. */
preserveManagedRuntime?: boolean;
/** Tear down this process's supervisor state (Core child, broker). */
stopSupervisor?: () => Promise<void>;
onDiagnostic?: (event: {
@@ -30,22 +19,16 @@ export interface RuntimeShutdownOptions {
export type RuntimeShutdownResult =
| { runtime: "stopped" }
| { runtime: "not-running" }
| { runtime: "preserved"; reason: string }
| { runtime: "unchanged" }
| { runtime: "failed"; detail: string };
/** Stop everything a full quit has to stop, and nothing else.
/** Stop everything a full quit has to stop.
*
* Exactly one Runtime serves a data dir. Whether quitting the App ends it is
* decided here, at quit time, from the service manager's answer:
*
* - background service enabled: the Runtime is the user's background memory
* service. Quit never stops it, whoever started the process, including a
* transient Runtime this App started earlier.
* - background service disabled: the Runtime lives only while the App does.
* Quit stops every Runtime of this data dir: the App's own child, one
* `memhub start` launched, a leftover from an earlier session.
* - state unknown (query failed or timed out): stop nothing, and say so.
* Quitting used to tear down this process's own handles and nothing else. In a
* packaged build the Runtime is a separate process, so it outlived the app: the
* window and the tray were gone and the Runtime was still there, and its
* receipt still named a pid, so the next launch concluded it was already
* running and attached to a process that no longer answered.
*
* A failure here is reported, never thrown. By the time this runs the app is
* already quitting, and throwing would only turn a failure someone could see
@@ -56,6 +39,15 @@ export async function shutdownRuntime(
): Promise<RuntimeShutdownResult> {
const stopSupervisor = options.stopSupervisor ?? (async () => {});
const supervisorFailure = await settle(stopSupervisor);
if (options.preserveManagedRuntime) {
await options.onDiagnostic?.({
outcome: supervisorFailure ? "degraded" : "succeeded",
detail: supervisorFailure ?? "managed-runtime-preserved",
});
return supervisorFailure
? { runtime: "failed", detail: supervisorFailure }
: { runtime: "unchanged" };
}
if (!options.standalone) {
if (supervisorFailure) {
await options.onDiagnostic?.({
@@ -66,52 +58,16 @@ export async function shutdownRuntime(
}
return { runtime: "unchanged" };
}
const preserved = async (
reason: string,
outcome: "succeeded" | "degraded",
): Promise<RuntimeShutdownResult> => {
await options.onDiagnostic?.({
outcome: supervisorFailure ? "degraded" : outcome,
detail: supervisorFailure ?? `runtime-preserved:${reason}`,
});
return supervisorFailure
? { runtime: "failed", detail: supervisorFailure }
: { runtime: "preserved", reason };
};
if (options.preserveReason) {
return preserved(options.preserveReason, "succeeded");
}
const service = await options.queryServiceState().catch(
(): RuntimeServiceState => ({
state: "unknown",
reason: "service-state-query-failed",
jobPid: null,
}),
);
if (service.state === "enabled") {
return preserved("background-service-enabled", "succeeded");
}
if (service.state !== "disabled") {
// Fail safe: a Runtime stopped by mistake takes the user's background
// memory with it; one left running by mistake is only a process.
return preserved(`service-state-unknown:${service.reason}`, "degraded");
}
try {
const result = await (options.stopRuntimes ?? stopAllRuntimeProcesses)({
dataDir: options.dataDir,
});
const stopped = result.stopped.length > 0 ? "stopped" : "not-running";
const unverified =
result.unverified.length > 0 ? "unverified-runtime-left-running" : null;
const result = await stopRuntimeProcess({ dataDir: options.dataDir });
await options.onDiagnostic?.({
outcome: supervisorFailure || unverified ? "degraded" : "succeeded",
detail: supervisorFailure ?? unverified ?? `${stopped}:${service.reason}`,
outcome: supervisorFailure ? "degraded" : "succeeded",
detail: supervisorFailure ?? result,
});
if (supervisorFailure) {
return { runtime: "failed", detail: supervisorFailure };
}
if (unverified) return { runtime: "failed", detail: unverified };
return { runtime: stopped };
return { runtime: result };
} catch (error) {
const detail = error instanceof Error ? error.message : "unknown";
await options.onDiagnostic?.({ outcome: "degraded", detail });
@@ -143,7 +143,6 @@ const SAFE_STABLE_RUNTIME_ERROR_CODES = new Set([
"sdk_migration_status_unavailable",
"sdk_migration_incomplete",
"runtime_service_not_ready",
"runtime_build_unknown",
]);
/** Every migration code is a stable token Core or the orchestrator chose;
@@ -243,8 +242,6 @@ export const STABLE_RUNTIME_USER_MESSAGES: Record<string, string> = {
"记忆数据格式升级没有完成,MemHub 暂时无法打开。请重新启动重试;如果仍然失败,请把这个提示反馈给我们,原有记忆数据不会丢失。",
runtime_service_not_ready:
"记忆服务仍在首次启动,请稍候后重试。你的记忆数据不会受到影响。",
runtime_build_unknown:
"无法确认正在运行的 MemHub 后台版本,App 已连接到它,但不会停止或替换它。如遇问题,请更新 App 或重启后台服务;你的记忆数据不会受到影响。",
desktop_start_failed:
"MemHub 启动失败。请重新启动;如果仍然打不开,请重新安装,你的记忆数据不会受到影响。",
};
@@ -545,7 +545,6 @@ const baseEnglishMessages = {
nativeDevelopmentUnavailable: " (unavailable in development)",
nativeOpen: "Open {product}",
nativeQuit: "Quit {product} (background service keeps running)",
nativeQuitStopsService: "Quit {product} (also stops the memory service)",
nativeLoginError: "Could not change the login startup setting. Please retry.",
nativeMoveTitle: "Move MemHub to Applications first",
nativeMoveDetail:
@@ -13,7 +13,6 @@ export const recoveryChineseMessages = {
nativeDevelopmentUnavailable: "(开发模式不可用)",
nativeOpen: "打开 {product}",
nativeQuit: "退出 {product}(后台服务继续运行)",
nativeQuitStopsService: "退出 {product}(同时停止记忆服务)",
nativeLoginError: "无法更改登录启动设置,请重试。",
nativeMoveTitle: "请先将 MemHub 拖到“应用程序”文件夹",
nativeMoveDetail:
@@ -116,8 +115,6 @@ export const recoverySpanishMessages: Record<
nativeDevelopmentUnavailable: " (no disponible en desarrollo)",
nativeOpen: "Abrir {product}",
nativeQuit: "Salir de {product} (el servicio sigue en segundo plano)",
nativeQuitStopsService:
"Salir de {product} (también detiene el servicio de memoria)",
nativeLoginError:
"No se pudo cambiar el inicio de sesión. Inténtalo de nuevo.",
nativeMoveTitle: "Arrastra MemHub a la carpeta Aplicaciones primero",
@@ -713,8 +713,6 @@ export const germanMessages: Record<MessageKey, string> = {
nativeDevelopmentUnavailable: " (in Entwicklung nicht verfügbar)",
nativeOpen: "{product} öffnen",
nativeQuit: "{product} beenden (Hintergrunddienst läuft weiter)",
nativeQuitStopsService:
"{product} beenden (Gedächtnisdienst wird ebenfalls beendet)",
nativeLoginError:
"Starteinstellung bei Anmeldung konnte nicht geändert werden. Bitte wiederholen Sie den Vorgang.",
nativeMoveTitle: "Verschieben Sie MemHub zuerst nach „Programme“",
@@ -723,8 +723,6 @@ export const frenchMessages: Record<MessageKey, string> = {
nativeOpen: "Ouvrir {product}",
nativeQuit:
"Quitter {product} (le service en arrière-plan continue de fonctionner)",
nativeQuitStopsService:
"Quitter {product} (le service de mémoire s’arrête aussi)",
nativeLoginError:
"Impossible de modifier le réglage de lancement à l’ouverture de session. Veuillez réessayer.",
nativeMoveTitle: "Déplacez d’abord MemHub dans Applications",
@@ -699,8 +699,6 @@ export const indonesianMessages: Record<MessageKey, string> = {
nativeDevelopmentUnavailable: " (tidak tersedia saat pengembangan)",
nativeOpen: "Buka {product}",
nativeQuit: "Keluar dari {product} (layanan latar belakang tetap berjalan)",
nativeQuitStopsService:
"Keluar dari {product} (layanan memori juga dihentikan)",
nativeLoginError:
"Tidak dapat mengubah pengaturan mulai saat masuk. Silakan coba lagi.",
nativeMoveTitle: "Pindahkan MemHub ke Aplikasi terlebih dahulu",
@@ -697,7 +697,6 @@ export const japaneseMessages: Record<MessageKey, string> = {
nativeDevelopmentUnavailable: " (開発モードでは利用不可)",
nativeOpen: "{product} を開く",
nativeQuit: "{product} を終了(バックグラウンドサービスは引き続き実行)",
nativeQuitStopsService: "{product} を終了(メモリサービスも停止)",
nativeLoginError:
"ログイン時の起動設定を変更できませんでした。もう一度お試しください。",
nativeMoveTitle:
@@ -678,7 +678,6 @@ export const koreanMessages: Record<MessageKey, string> = {
nativeDevelopmentUnavailable: " (개발 모드에서 사용 불가)",
nativeOpen: "{product} 열기",
nativeQuit: "{product} 종료(백그라운드 서비스는 계속 실행)",
nativeQuitStopsService: "{product} 종료(메모리 서비스도 중지)",
nativeLoginError:
"로그인 시 실행 설정을 변경하지 못했습니다. 다시 시도하세요.",
nativeMoveTitle: "먼저 MemHub를 응용 프로그램 폴더로 옮기세요",
@@ -718,8 +718,6 @@ export const portugueseMessages: Record<MessageKey, string> = {
nativeOpen: "Abrir {product}",
nativeQuit:
"Sair do {product} (o serviço em segundo plano continua em execução)",
nativeQuitStopsService:
"Sair do {product} (o serviço de memória também é encerrado)",
nativeLoginError:
"Não foi possível alterar a configuração de abertura ao iniciar sessão. Tente novamente.",
nativeMoveTitle: "Mova o MemHub para Aplicativos primeiro",
@@ -705,7 +705,6 @@ export const russianMessages: Record<MessageKey, string> = {
nativeDevelopmentUnavailable: " (недоступно в режиме разработки)",
nativeOpen: "Открыть {product}",
nativeQuit: "Выйти из {product} (фоновая служба продолжит работу)",
nativeQuitStopsService: "Выйти из {product} (служба памяти тоже остановится)",
nativeLoginError:
"Не удалось изменить настройку запуска при входе. Повторите попытку.",
nativeMoveTitle: "Сначала переместите MemHub в «Программы»",
@@ -625,7 +625,6 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
nativeDevelopmentUnavailable: "(開發模式不可用)",
nativeOpen: "開啟 {product}",
nativeQuit: "結束 {product}(背景服務會繼續執行)",
nativeQuitStopsService: "結束 {product}(同時停止記憶服務)",
nativeLoginError: "無法更改登入啟動設定,請重試。",
nativeMoveTitle: "請先將 MemHub 移到「應用程式」檔案夾",
nativeMoveDetail:
@@ -16,18 +16,9 @@ export interface RuntimeAttachment {
* `degraded`, never `healthy`. Null/absent when the receipt predates the
* field (an older Runtime), which is treated as "unknown", not "healthy". */
sidecarState?: "running" | "failed" | "stopped" | null;
/** Build the Runtime published in its receipt. `undefined` when the receipt
* predates the field (a Runtime older than every build that publishes it);
* `null` members when the Runtime does not know its own build. */
runtimeBuild?: RuntimeReceiptBuild;
health: RuntimeIdentity;
}
export interface RuntimeReceiptBuild {
buildNumber: number | null;
commit: string | null;
}
export interface RuntimeAttachmentOptions {
dataDir: string;
processIsAlive(pid: number): boolean;
@@ -72,7 +63,6 @@ interface RuntimeServiceReceipt {
status: "starting" | "running" | "stopping";
corePort: number | null;
sidecarState: "running" | "failed" | "stopped" | null;
build?: RuntimeReceiptBuild;
}
/** The Core port a *running* managed Runtime published, or null when there is
@@ -153,9 +143,6 @@ export async function discoverRuntimeAttachment(
port: receipt.port,
servicePid: service?.pid ?? null,
sidecarState: service?.sidecarState ?? null,
...(service?.build === undefined
? {}
: { runtimeBuild: Object.freeze({ ...service.build }) }),
health: Object.freeze({ ...health }),
});
}
@@ -203,7 +190,6 @@ async function readRuntimeServiceReceipt(
) {
throw new RuntimeDiscoveryError("runtime_service_receipt_invalid");
}
const build = receiptBuild(item);
return {
instanceId: item.instance_id,
pid: item.pid,
@@ -213,28 +199,6 @@ async function readRuntimeServiceReceipt(
item.sidecar === undefined
? null
: (item.sidecar as RuntimeServiceReceipt["sidecarState"]),
...(build === undefined ? {} : { build }),
};
}
/** A malformed build field reads as "unknown", never as a receipt error: the
* build only decides whether an App adopts this Runtime, and an unknown build
* makes it leave the Runtime alone. */
function receiptBuild(
item: Record<string, unknown>,
): RuntimeReceiptBuild | undefined {
if (!("build_number" in item) && !("git_commit" in item)) return undefined;
const build = item.build_number;
const commit = item.git_commit;
return {
buildNumber:
typeof build === "number" && Number.isSafeInteger(build) && build > 0
? build
: null,
commit:
typeof commit === "string" && /^[0-9a-f]{7,40}$/.test(commit)
? commit
: null,
};
}
@@ -71,7 +71,6 @@ export {
type RuntimeAttachmentOptions,
RuntimeDiscoveryError,
type RuntimeIdentity,
type RuntimeReceiptBuild,
readPublishedCorePort,
} from "./discovery.js";
export {
@@ -145,17 +144,12 @@ export {
type RuntimeCliConfig,
resolveRuntimeCliConfig,
runRuntimeCli,
runtimeBuildIdentity,
} from "./runtime-cli.js";
export {
type EnsureRuntimeOptions,
ensureRuntimeProcess,
type RuntimeLaunch,
readRuntimeLeaseOwnerPid,
type StopAllRuntimesOptions,
type StopAllRuntimesResult,
type StopRuntimeOptions,
stopAllRuntimeProcesses,
stopRuntimeProcess,
type WaitForRuntimeOptions,
waitForRuntimeProcess,
@@ -166,7 +160,6 @@ export {
} from "./runtime-maintenance.js";
export {
type ManagedRuntime,
type RuntimeBuildIdentity,
RuntimeService,
type RuntimeServiceOptions,
} from "./runtime-service.js";
@@ -242,15 +235,6 @@ export {
runtimeInstallLayout,
stableRuntimeInstallRoot,
} from "./service-plan.js";
export {
parseLaunchctlPid,
queryRuntimeServiceState,
type RuntimeServiceState,
type RuntimeServiceStateName,
type RuntimeServiceStateOptions,
type RuntimeServiceStateReason,
type ServiceStateProbe,
} from "./service-state.js";
export { createSourceChangeHandler } from "./source-change-handler.js";
export {
type ApplySyncHookInput,
@@ -24,7 +24,6 @@ import {
resolveRuntimeCliConfig,
revalidateHostRuntime,
runRuntimeCli,
runtimeBuildIdentity,
waitForAppRemoval,
} from "./runtime-cli.js";
import {
@@ -1031,18 +1030,3 @@ describe("Runtime CLI service plan", () => {
);
});
});
describe("the build a Runtime publishes in its receipt", () => {
it("is its build number and commit, and null where it does not know", () => {
expect(
runtimeBuildIdentity({ buildNumber: "38", commit: "ABCDEF1234" }),
).toEqual({ buildNumber: 38, commit: "abcdef1234" });
expect(runtimeBuildIdentity(undefined)).toEqual({
buildNumber: null,
commit: null,
});
expect(
runtimeBuildIdentity({ buildNumber: "dev", commit: "unknown" }),
).toEqual({ buildNumber: null, commit: null });
});
});
@@ -18,10 +18,7 @@ import { HostPermissions } from "./host-permissions.js";
import { verifyInstalledDistribution } from "./local-install.js";
import { RecallBrokerSupervisor } from "./recall-broker-supervisor.js";
import { recallHosts } from "./recall-hosts.js";
import {
type RuntimeBuildIdentity,
RuntimeService,
} from "./runtime-service.js";
import { RuntimeService } from "./runtime-service.js";
import { RuntimeSupervisor } from "./runtime-supervisor.js";
import {
runSdkMigration,
@@ -656,19 +653,6 @@ function boundFingerprint(
.digest("hex");
}
/** The build this Runtime publishes in its receipt. Unknown stays `null`, never
* a guess: an App treats an unknown build as "do not touch". */
export function runtimeBuildIdentity(
identity: RuntimeCliConfig["buildIdentity"],
): RuntimeBuildIdentity {
const build = Number(identity?.buildNumber);
const commit = identity?.commit?.trim().toLowerCase() ?? "";
return {
buildNumber: Number.isSafeInteger(build) && build > 0 ? build : null,
commit: /^[0-9a-f]{7,40}$/.test(commit) ? commit : null,
};
}
export async function createRuntimeService(
config: RuntimeCliConfig,
): Promise<RuntimeService> {
@@ -684,7 +668,6 @@ export async function createRuntimeService(
return new RuntimeService({
dataDir: config.dataDir,
diagnostics,
buildIdentity: runtimeBuildIdentity(config.buildIdentity),
async createSupervisor(onSidecarFault) {
const { port, overrideApplied, ignoredReason } = resolveCorePort(
process.env,
@@ -1,13 +1,12 @@
import type { ChildProcess, SpawnOptions } from "node:child_process";
import { spawn } from "node:child_process";
import { access, readFile } from "node:fs/promises";
import { access } from "node:fs/promises";
import path from "node:path";
import type { HealthResponse } from "@one-memory/contracts";
import axios from "axios";
import { processIsAlive } from "./core-supervisor.js";
import {
discoverRuntimeAttachment,
type RuntimeAttachment,
RuntimeDiscoveryError,
} from "./discovery.js";
import { readSdkMigrationBlock } from "./sdk-migration-log.js";
@@ -110,7 +109,7 @@ export async function ensureRuntimeProcess(
export async function waitForRuntimeProcess(
options: WaitForRuntimeOptions,
): Promise<RuntimeAttachment> {
): Promise<void> {
const attempts = options.attempts ?? 120;
const delayMs = options.delayMs ?? 250;
const waitStarted = Date.now();
@@ -122,7 +121,7 @@ export async function waitForRuntimeProcess(
probeIdentity: options.probeIdentity ?? defaultProbeIdentity,
requireServiceReceipt: true,
});
if (attachment !== null) return attachment;
if (attachment !== null) return;
} catch (error) {
if (
!(error instanceof RuntimeDiscoveryError) ||
@@ -162,117 +161,8 @@ export async function stopRuntimeProcess(
if (attachment.servicePid === null) {
throw new RuntimeDiscoveryError("runtime_service_receipt_invalid");
}
await terminateVerifiedRuntime(options, attachment.servicePid);
return "stopped";
}
export interface StopAllRuntimesOptions extends StopRuntimeOptions {
/** How long a Runtime caught starting or stopping may take to become
* verifiable before the stop gives up on it. */
settleMs?: number;
/** Upper bound on Runtimes stopped in one call. The lease admits one at a
* time, so more than a few means something keeps starting new ones. */
maxRuntimes?: number;
}
export interface StopAllRuntimesResult {
/** Verified Runtime processes this call stopped. */
stopped: number[];
/** A live pid the Runtime lease names that could not be verified as a
* MemHub Runtime of this data dir. Never signalled: a recycled pid belongs
* to someone else. */
unverified: number[];
}
/** Stop every Runtime serving `dataDir`, whoever started it.
*
* The Runtime lease (`runtime-service.lock`) admits one Runtime per data dir,
* so "every" is the current leaseholder plus any successor that takes the
* lease while this runs: the App's own transient child, a `memhub start`, a
* leftover from an earlier session. Each is found through its receipt and
* signalled only once the authenticated identity endpoint has proved that the
* receipt's pid is the Runtime serving this data dir. A live lease owner that
* cannot be proved that way is reported, never signalled.
*/
export async function stopAllRuntimeProcesses(
options: StopAllRuntimesOptions,
): Promise<StopAllRuntimesResult> {
const alive = options.processIsAlive ?? processIsAlive;
const stopped: number[] = [];
const settleDeadline = Date.now() + (options.settleMs ?? 2_000);
const maxRuntimes = options.maxRuntimes ?? 3;
while (stopped.length < maxRuntimes) {
let attachment: RuntimeAttachment | null;
try {
attachment = await discoverRuntimeAttachment({
dataDir: options.dataDir,
processIsAlive: alive,
probeIdentity: options.probeIdentity ?? defaultProbeIdentity,
requireServiceReceipt: true,
});
} catch (error) {
if (settling(error) && Date.now() < settleDeadline) {
await delay(100);
continue;
}
throw error;
}
if (attachment === null) break;
if (attachment.servicePid === null) {
throw new RuntimeDiscoveryError("runtime_service_receipt_invalid");
}
await terminateVerifiedRuntime(options, attachment.servicePid);
stopped.push(attachment.servicePid);
}
const leasePid = await readRuntimeLeaseOwnerPid(options.dataDir);
const unverified =
leasePid !== null &&
leasePid !== process.pid &&
!stopped.includes(leasePid) &&
alive(leasePid)
? [leasePid]
: [];
return { stopped, unverified };
}
/** The pid the Runtime lease names, or null without a readable lease. */
export async function readRuntimeLeaseOwnerPid(
dataDir: string,
): Promise<number | null> {
try {
const owner = JSON.parse(
await readFile(
path.join(dataDir, "runtime-service.lock", "owner.json"),
"utf8",
),
) as { pid?: unknown };
return typeof owner.pid === "number" &&
Number.isInteger(owner.pid) &&
owner.pid > 1
? owner.pid
: null;
} catch {
return null;
}
}
/** A Runtime between states: starting, stopping, or swapping receipts. */
function settling(error: unknown): boolean {
return (
error instanceof RuntimeDiscoveryError &&
(error.code === "runtime_service_not_ready" ||
error.code === "runtime_service_receipt_invalid" ||
error.code === "runtime_identity_unavailable")
);
}
async function terminateVerifiedRuntime(
options: StopRuntimeOptions,
pid: number,
): Promise<void> {
const alive = options.processIsAlive ?? processIsAlive;
const signal = options.signalProcess ?? process.kill;
signal(pid, "SIGTERM");
signal(attachment.servicePid, "SIGTERM");
const timeoutMs = options.timeoutMs ?? 15_000;
const deadline = Date.now() + timeoutMs;
// The process exiting is the authoritative stop signal. The receipt files
@@ -281,10 +171,10 @@ async function terminateVerifiedRuntime(
const receiptGraceMs = Math.min(RECEIPT_CLEANUP_GRACE_MS, timeoutMs);
let exitedAt: number | null = null;
while (Date.now() < deadline) {
if (!alive(pid)) {
if (!alive(attachment.servicePid)) {
exitedAt ??= Date.now();
if (await receiptsAreGone(options.dataDir)) return;
if (Date.now() - exitedAt >= receiptGraceMs) return;
if (await receiptsAreGone(options.dataDir)) return "stopped";
if (Date.now() - exitedAt >= receiptGraceMs) return "stopped";
} else {
exitedAt = null;
}
@@ -73,23 +73,6 @@ async function lockEntries(dataDir: string): Promise<string[]> {
}
describe("RuntimeService", () => {
it("publishes its build so an App can compare it before adopting", async () => {
const dataDir = await directory("runtime-service-build-");
const service = new RuntimeService({
dataDir,
createSupervisor: () => supervisor(),
pid: 4321,
buildIdentity: { buildNumber: 38, commit: null },
});
await service.start();
const receipt = JSON.parse(
await readFile(path.join(dataDir, "runtime-service.json"), "utf8"),
);
expect(receipt).toMatchObject({ build_number: 38, git_commit: null });
await service.stop();
});
it("publishes and removes an outer Runtime receipt without secrets", async () => {
const dataDir = await directory("runtime-service-");
const runtime = supervisor();
@@ -46,16 +46,6 @@ export interface RuntimeServiceOptions {
* owner.json; see removeUnownedLease. Injectable for tests. */
legacyBootTime?: () => number | null;
diagnostics?: DiagnosticSink;
/** Build this process runs, published in the receipt so an App can tell an
* older or newer Runtime from its own before adopting it. `null` fields mean
* "this Runtime does not know"; a receipt without the fields at all was
* written by a Runtime older than this contract. */
buildIdentity?: RuntimeBuildIdentity;
}
export interface RuntimeBuildIdentity {
buildNumber: number | null;
commit: string | null;
}
interface LeaseIdentity {
@@ -416,12 +406,6 @@ export class RuntimeService {
pid: this.pid(),
core_port: Number(url.port),
...(sidecar === undefined ? {} : { sidecar }),
...(this.options.buildIdentity === undefined
? {}
: {
build_number: this.options.buildIdentity.buildNumber,
git_commit: this.options.buildIdentity.commit,
}),
started_at: new Date().toISOString(),
},
null,
@@ -1,250 +0,0 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
stopAllRuntimeProcesses,
waitForRuntimeProcess,
} from "./runtime-launcher.js";
async function writeRuntime(
dataDir: string,
runtime: {
instance: string;
servicePid: number;
corePid: number;
build?: Record<string, unknown>;
},
) {
await mkdir(dataDir, { recursive: true });
await writeFile(
path.join(dataDir, "runtime.json"),
JSON.stringify({
schema_version: 1,
service: "one-memory-core",
instance_id: runtime.instance,
pid: runtime.corePid,
parent_pid: runtime.servicePid,
host: "127.0.0.1",
port: 8765,
started_at: "now",
}),
);
await writeFile(path.join(dataDir, "runtime-token"), "token", {
mode: 0o600,
});
await writeFile(
path.join(dataDir, "runtime-service.json"),
JSON.stringify({
schema_version: 1,
service: "one-memory-runtime",
status: "running",
instance_id: runtime.instance,
pid: runtime.servicePid,
core_port: 8765,
...runtime.build,
started_at: "now",
}),
{ mode: 0o600 },
);
}
async function writeLease(dataDir: string, pid: number) {
await mkdir(path.join(dataDir, "runtime-service.lock"), { recursive: true });
await writeFile(
path.join(dataDir, "runtime-service.lock", "owner.json"),
JSON.stringify({ pid, created_at: "now", boot_id: null }),
);
}
async function removeRuntime(dataDir: string) {
for (const name of ["runtime.json", "runtime-service.json", "runtime-token"])
await rm(path.join(dataDir, name), { force: true });
await rm(path.join(dataDir, "runtime-service.lock"), {
recursive: true,
force: true,
});
}
const identityFor = (live: Map<number, string>) =>
async function probeIdentity() {
// The identity endpoint answers for whichever Runtime holds the receipts.
const instance = [...live.values()].at(-1) ?? "none";
return {
status: "ok" as const,
service: "one-memory-core" as const,
version: "1.0.0",
instance_id: instance,
};
};
describe("stopping every Runtime of a data dir", () => {
it("stops the App's own child and an externally started successor", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-stop-all-"));
// pid -> instance; core pids are always alive while their service is.
const live = new Map<number, string>([[4321, "app-child"]]);
await writeRuntime(dataDir, {
instance: "app-child",
servicePid: 4321,
corePid: 1234,
});
await writeLease(dataDir, 4321);
const signalProcess = vi.fn(async (pid: number) => {
live.delete(pid);
await removeRuntime(dataDir);
if (pid === 4321) {
// `memhub start` was waiting on the lease and takes over at once.
live.set(5555, "cli-started");
await writeRuntime(dataDir, {
instance: "cli-started",
servicePid: 5555,
corePid: 5556,
});
await writeLease(dataDir, 5555);
}
});
const result = await stopAllRuntimeProcesses({
dataDir,
timeoutMs: 2_000,
processIsAlive: (pid) =>
live.has(pid) ||
(pid === 1234 && live.has(4321)) ||
(pid === 5556 && live.has(5555)),
signalProcess: (pid) => void signalProcess(pid),
probeIdentity: identityFor(live),
});
expect(result).toEqual({ stopped: [4321, 5555], unverified: [] });
expect(signalProcess.mock.calls.map(([pid]) => pid)).toEqual([4321, 5555]);
});
it("never signals a live lease owner it cannot verify as a MemHub Runtime", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-stop-all-"));
// A lease left by a crashed Runtime whose pid the OS gave to another
// program: no receipt, no identity endpoint, just a live number.
await writeLease(dataDir, 7777);
const signalProcess = vi.fn();
const result = await stopAllRuntimeProcesses({
dataDir,
processIsAlive: (pid) => pid === 7777,
signalProcess,
probeIdentity: async () => {
throw new Error("no endpoint");
},
});
expect(result).toEqual({ stopped: [], unverified: [7777] });
expect(signalProcess).not.toHaveBeenCalled();
});
it("refuses to signal a receipt pid whose identity does not match", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-stop-all-"));
await writeRuntime(dataDir, {
instance: "runtime",
servicePid: 4321,
corePid: 1234,
});
const signalProcess = vi.fn();
await expect(
stopAllRuntimeProcesses({
dataDir,
processIsAlive: () => true,
signalProcess,
probeIdentity: async () => ({
status: "ok",
service: "one-memory-core",
version: "1.0.0",
instance_id: "someone-else",
}),
}),
).rejects.toThrow("runtime_identity_mismatch");
expect(signalProcess).not.toHaveBeenCalled();
});
it("reports nothing to stop for an empty data dir", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-stop-all-"));
await expect(
stopAllRuntimeProcesses({
dataDir,
processIsAlive: () => false,
signalProcess: vi.fn(),
}),
).resolves.toEqual({ stopped: [], unverified: [] });
});
});
describe("the build a running Runtime publishes", () => {
const probeIdentity = async () => ({
status: "ok" as const,
service: "one-memory-core" as const,
version: "1.0.0",
instance_id: "runtime",
});
it("is read from its receipt", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-build-"));
await writeRuntime(dataDir, {
instance: "runtime",
servicePid: 4321,
corePid: 1234,
build: { build_number: 38, git_commit: "abcdef1" },
});
const attachment = await waitForRuntimeProcess({
dataDir,
attempts: 1,
processIsAlive: () => true,
probeIdentity,
});
expect(attachment.servicePid).toBe(4321);
expect(attachment.runtimeBuild).toEqual({
buildNumber: 38,
commit: "abcdef1",
});
});
it("is absent for a receipt from a Runtime older than the field", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-build-"));
await writeRuntime(dataDir, {
instance: "runtime",
servicePid: 4321,
corePid: 1234,
});
const attachment = await waitForRuntimeProcess({
dataDir,
attempts: 1,
processIsAlive: () => true,
probeIdentity,
});
expect(attachment.runtimeBuild).toBeUndefined();
});
it("reads a malformed build as unknown, not as a broken receipt", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-build-"));
await writeRuntime(dataDir, {
instance: "runtime",
servicePid: 4321,
corePid: 1234,
build: { build_number: "38", git_commit: "not a commit" },
});
const attachment = await waitForRuntimeProcess({
dataDir,
attempts: 1,
processIsAlive: () => true,
probeIdentity,
});
expect(attachment.runtimeBuild).toEqual({
buildNumber: null,
commit: null,
});
});
});
@@ -1,339 +0,0 @@
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
parseLaunchctlPid,
queryRuntimeServiceState,
type ServiceStateProbe,
} from "./service-state.js";
const LABEL = "com.tencent.onememory.runtime";
async function home() {
const root = await mkdtemp(path.join(tmpdir(), "service-state-"));
const dataDir = path.join(root, ".one-memory");
await mkdir(dataDir, { recursive: true });
return { root, dataDir };
}
async function writeLaunchAgent(root: string, dataDir: string, label = LABEL) {
const directory = path.join(root, "Library", "LaunchAgents");
await mkdir(directory, { recursive: true });
await writeFile(
path.join(directory, `${label}.plist`),
`<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict>
<key>Label</key><string>${label}</string>
<key>EnvironmentVariables</key><dict><key>ONE_MEMORY_DATA_DIR</key><string>${dataDir.replaceAll("&", "&amp;")}</string></dict>
</dict></plist>
`,
);
}
function launchctl(options: {
disabled?: boolean;
job?: { code: number; pid?: number };
overridesCode?: number;
}): ServiceStateProbe {
return vi.fn(async (command: readonly string[]) => {
if (command[1] === "print-disabled") {
return {
code: options.overridesCode ?? 0,
stdout: `disabled services = {\n\t"${LABEL}" => ${options.disabled ? "disabled" : "enabled"}\n}\n`,
};
}
const job = options.job ?? { code: 0, pid: 4321 };
return {
code: job.code,
stdout:
job.code === 0
? `${LABEL} = {\n\tpath = /x.plist\n${job.pid ? `\tpid = ${job.pid}\n` : ""}\tendpoints = {\n\t\tpid = 1\n\t}\n}\n`
: "",
};
});
}
describe("asking launchd whether the background service is on", () => {
it("is enabled for an installed, loaded job with its enable marker", async () => {
const { root, dataDir } = await home();
await writeLaunchAgent(root, dataDir);
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "darwin",
uid: 501,
probe: launchctl({}),
markerEnabled: async () => true,
}),
).resolves.toEqual({
state: "enabled",
reason: "service-enabled",
jobPid: 4321,
});
});
it("is disabled when no LaunchAgent serves this data dir", async () => {
const { root, dataDir } = await home();
const probe = launchctl({});
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "darwin",
uid: 501,
probe,
markerEnabled: async () => true,
}),
).resolves.toMatchObject({
state: "disabled",
reason: "service-not-installed",
});
expect(probe).not.toHaveBeenCalled();
});
it("ignores a LaunchAgent that serves another data dir", async () => {
const { root, dataDir } = await home();
await writeLaunchAgent(root, path.join(root, "other-data"));
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "darwin",
uid: 501,
probe: launchctl({}),
markerEnabled: async () => true,
}),
).resolves.toMatchObject({
state: "disabled",
reason: "service-not-installed",
});
});
it("finds an isolated install root's scoped label", async () => {
const { root, dataDir } = await home();
await writeLaunchAgent(root, dataDir, `${LABEL}.0123456789abcdef`);
const probe: ServiceStateProbe = async (command) =>
command[1] === "print-disabled"
? { code: 0, stdout: "" }
: command[2] === `gui/501/${LABEL}.0123456789abcdef`
? { code: 0, stdout: "\tpid = 999\n" }
: { code: 113, stdout: "" };
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "darwin",
uid: 501,
probe,
markerEnabled: async () => true,
}),
).resolves.toEqual({
state: "enabled",
reason: "service-enabled",
jobPid: 999,
});
});
it("is disabled under a launchctl disable override (the transient fallback)", async () => {
const { root, dataDir } = await home();
await writeLaunchAgent(root, dataDir);
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "darwin",
uid: 501,
probe: launchctl({ disabled: true, job: { code: 113 } }),
markerEnabled: async () => true,
}),
).resolves.toEqual({
state: "disabled",
reason: "service-disabled-by-launchctl",
jobPid: null,
});
});
it("is disabled when Launch at login is off, even with the job still loaded", async () => {
const { root, dataDir } = await home();
await writeLaunchAgent(root, dataDir);
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "darwin",
uid: 501,
probe: launchctl({}),
markerEnabled: async () => false,
}),
).resolves.toEqual({
state: "disabled",
reason: "service-enable-marker-absent",
jobPid: 4321,
});
});
it("stays enabled for an installed job launchd has not loaded yet", async () => {
const { root, dataDir } = await home();
await writeLaunchAgent(root, dataDir);
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "darwin",
uid: 501,
probe: launchctl({ job: { code: 113 } }),
markerEnabled: async () => true,
}),
).resolves.toEqual({
state: "enabled",
reason: "service-enabled",
jobPid: null,
});
});
it.each([
["the override table cannot be read", launchctl({ overridesCode: 1 })],
["the job query fails", launchctl({ job: { code: 5 } })],
[
"launchctl cannot run",
(async () => {
throw new Error("spawn failed");
}) as ServiceStateProbe,
],
])("is unknown when %s", async (_case, probe) => {
const { root, dataDir } = await home();
await writeLaunchAgent(root, dataDir);
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "darwin",
uid: 501,
probe,
markerEnabled: async () => true,
}),
).resolves.toMatchObject({ state: "unknown" });
});
it("is unknown when the query outlives its budget", async () => {
const { root, dataDir } = await home();
await writeLaunchAgent(root, dataDir);
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "darwin",
uid: 501,
timeoutMs: 20,
probe: () => new Promise(() => {}),
markerEnabled: async () => true,
}),
).resolves.toEqual({
state: "unknown",
reason: "service-state-timeout",
jobPid: null,
});
});
it("is unknown without a user id to name the launchd domain", async () => {
const { root, dataDir } = await home();
await writeLaunchAgent(root, dataDir);
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "darwin",
probe: launchctl({}),
markerEnabled: async () => true,
}),
).resolves.toMatchObject({ state: "unknown" });
});
});
describe("asking systemd whether the background service is on", () => {
async function writeUnit(root: string, dataDir: string) {
const directory = path.join(root, ".config", "systemd", "user");
await mkdir(directory, { recursive: true });
await writeFile(
path.join(directory, "one-memory.service"),
`[Service]\nEnvironment="ONE_MEMORY_DATA_DIR=${dataDir}"\n`,
);
}
const systemctl =
(answer: string, code = 0): ServiceStateProbe =>
async (command) =>
command.includes("is-enabled")
? { code, stdout: `${answer}\n` }
: { code: 0, stdout: "2468\n" };
it("follows systemctl is-enabled", async () => {
const { root, dataDir } = await home();
await writeUnit(root, dataDir);
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "linux",
probe: systemctl("enabled"),
}),
).resolves.toEqual({
state: "enabled",
reason: "service-enabled",
jobPid: 2468,
});
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "linux",
probe: systemctl("disabled", 1),
}),
).resolves.toMatchObject({
state: "disabled",
reason: "service-disabled-by-systemd",
});
});
it("is disabled without a unit and unknown without an answer", async () => {
const { root, dataDir } = await home();
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "linux",
probe: systemctl("enabled"),
}),
).resolves.toMatchObject({
state: "disabled",
reason: "service-not-installed",
});
await writeUnit(root, dataDir);
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "linux",
probe: systemctl("", 1),
}),
).resolves.toMatchObject({ state: "unknown" });
});
});
it("reads only the job's own top-level pid", () => {
expect(parseLaunchctlPid("x = {\n\tpid = 77\n\tendpoints = {\n}\n")).toBe(77);
expect(parseLaunchctlPid("x = {\n\tstate = waiting\n}\n")).toBeNull();
});
@@ -1,317 +0,0 @@
import { spawn } from "node:child_process";
import { readdir, readFile, realpath } from "node:fs/promises";
import path from "node:path";
import { parseDisabledOverride } from "./service-install.js";
import { runtimeServiceEnabled } from "./service-ownership.js";
import { RUNTIME_SERVICE_ID } from "./service-plan.js";
/**
* Whether the background service would keep a Runtime for this data dir alive
* without the App.
*
* `enabled`: a LaunchAgent (or systemd user unit) declaring this data dir is
* installed, the OS has no disable override for it, and on macOS the enable
* marker its `KeepAlive` waits on is present. The marker is the App's "Launch
* at login" switch and `memhub service disable`: without it launchd neither
* starts nor restarts the job, so the service is off even while the plist is
* still installed.
*
* `disabled`: none of that holds. The service was never installed or was
* uninstalled, `launchctl disable` / `systemctl disable` turned it off (the
* case in which the App falls back to a transient Runtime), or the enable
* marker is gone.
*
* `unknown`: a query failed or timed out. Callers must not act on it.
*/
export type RuntimeServiceStateName = "enabled" | "disabled" | "unknown";
export type RuntimeServiceStateReason =
| "service-enabled"
| "service-not-installed"
| "service-enable-marker-absent"
| "service-disabled-by-launchctl"
| "service-disabled-by-systemd"
| "service-unsupported-platform"
| "service-state-query-failed"
| "service-state-timeout";
export interface RuntimeServiceState {
state: RuntimeServiceStateName;
reason: RuntimeServiceStateReason;
/** The pid the service manager reports for the running job: null when the
* job is not loaded, not running, or the state is unknown. */
jobPid: number | null;
}
export type ServiceStateProbe = (
command: readonly string[],
timeoutMs: number,
) => Promise<{ code: number | null; stdout: string }>;
export interface RuntimeServiceStateOptions {
dataDir: string;
home: string;
platform?: NodeJS.Platform;
uid?: number;
xdgConfigHome?: string;
/** Budget for the whole query; past it the state is `unknown`. */
timeoutMs?: number;
probe?: ServiceStateProbe;
markerEnabled?: (dataDir: string) => Promise<boolean>;
}
const LAUNCH_AGENT_FILE = new RegExp(
`^${RUNTIME_SERVICE_ID.replaceAll(".", "\\.")}(?:\\.[0-9a-f]{16})?\\.plist$`,
);
/** Ask the service manager, now, whether the background service is on.
*
* Never throws and never caches: every answer is a fresh `launchctl` /
* `systemctl` read, and any failure or timeout is `unknown`.
*/
export async function queryRuntimeServiceState(
options: RuntimeServiceStateOptions,
): Promise<RuntimeServiceState> {
const timeoutMs = options.timeoutMs ?? 3_000;
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<RuntimeServiceState>((resolve) => {
timer = setTimeout(
() => resolve(state("unknown", "service-state-timeout")),
timeoutMs,
);
});
try {
return await Promise.race([
inspect(options, timeoutMs).catch(() =>
state("unknown", "service-state-query-failed"),
),
timeout,
]);
} finally {
clearTimeout(timer);
}
}
async function inspect(
options: RuntimeServiceStateOptions,
timeoutMs: number,
): Promise<RuntimeServiceState> {
const platform = options.platform ?? process.platform;
const probe = options.probe ?? runProbe;
if (platform === "darwin")
return inspectLaunchAgent(options, probe, timeoutMs);
if (platform === "linux")
return inspectSystemdUnit(options, probe, timeoutMs);
return state("disabled", "service-unsupported-platform");
}
async function inspectLaunchAgent(
options: RuntimeServiceStateOptions,
probe: ServiceStateProbe,
timeoutMs: number,
): Promise<RuntimeServiceState> {
const uid = options.uid;
if (uid === undefined || !Number.isSafeInteger(uid) || uid < 1) {
return state("unknown", "service-state-query-failed");
}
const labels = await launchAgentsServing(options.home, options.dataDir);
if (labels.length === 0) return state("disabled", "service-not-installed");
const domain = `gui/${uid}`;
const overrides = await probe(
["launchctl", "print-disabled", domain],
timeoutMs,
);
if (overrides.code !== 0) {
return state("unknown", "service-state-query-failed");
}
const marker = await (options.markerEnabled ?? runtimeServiceEnabled)(
options.dataDir,
);
let first: RuntimeServiceState | null = null;
for (const label of labels) {
const job = await probe(
["launchctl", "print", `${domain}/${label}`],
timeoutMs,
);
// 113: the label is not loaded. Anything else unexpected is unknown.
if (job.code !== 0 && job.code !== 113) {
return state("unknown", "service-state-query-failed");
}
const jobPid = job.code === 0 ? parseLaunchctlPid(job.stdout) : null;
const current = parseDisabledOverride(overrides.stdout, label)
? state("disabled", "service-disabled-by-launchctl", jobPid)
: marker
? state("enabled", "service-enabled", jobPid)
: state("disabled", "service-enable-marker-absent", jobPid);
if (current.state === "enabled") return current;
first ??= current;
}
return first ?? state("disabled", "service-not-installed");
}
/** Labels of the MemHub LaunchAgents whose declaration serves `dataDir`.
* The daily install and isolated install roots use different labels, and a
* LaunchAgent for another data dir is not this data dir's service. */
async function launchAgentsServing(
home: string,
dataDir: string,
): Promise<string[]> {
const directory = path.join(home, "Library", "LaunchAgents");
let names: string[];
try {
names = await readdir(directory);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
}
const wanted = await canonical(dataDir);
const labels: string[] = [];
for (const name of names
.filter((item) => LAUNCH_AGENT_FILE.test(item))
.sort()) {
const label = name.slice(0, -".plist".length);
const content = await readFile(path.join(directory, name), "utf8");
if (!content.includes(`<key>Label</key><string>${label}</string>`))
continue;
const declared =
/<key>ONE_MEMORY_DATA_DIR<\/key><string>([^<]*)<\/string>/.exec(
content,
)?.[1];
if (declared === undefined) continue;
if ((await canonical(unescapeXml(declared))) === wanted) labels.push(label);
}
return labels;
}
async function inspectSystemdUnit(
options: RuntimeServiceStateOptions,
probe: ServiceStateProbe,
timeoutMs: number,
): Promise<RuntimeServiceState> {
const config = path.resolve(
options.xdgConfigHome?.trim() || path.join(options.home, ".config"),
);
let content: string;
try {
content = await readFile(
path.join(config, "systemd", "user", "one-memory.service"),
"utf8",
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return state("disabled", "service-not-installed");
}
throw error;
}
const declared =
/^Environment="ONE_MEMORY_DATA_DIR=((?:[^"\\]|\\.)*)"$/m.exec(content)?.[1];
if (
declared === undefined ||
(await canonical(declared.replace(/\\(.)/g, "$1"))) !==
(await canonical(options.dataDir))
) {
return state("disabled", "service-not-installed");
}
const enabled = await probe(
["systemctl", "--user", "is-enabled", "one-memory.service"],
timeoutMs,
);
const answer = enabled.stdout.trim().split(/\r?\n/)[0] ?? "";
if (enabled.code === null || answer === "") {
return state("unknown", "service-state-query-failed");
}
const main = await probe(
[
"systemctl",
"--user",
"show",
"one-memory.service",
"--property=MainPID",
"--value",
],
timeoutMs,
);
const pid = Number(main.stdout.trim());
const jobPid =
main.code === 0 && Number.isSafeInteger(pid) && pid > 1 ? pid : null;
if (answer === "enabled" || answer === "enabled-runtime") {
return state("enabled", "service-enabled", jobPid);
}
return state("disabled", "service-disabled-by-systemd", jobPid);
}
/** The top-level `pid = N` launchctl prints for a running job. Top-level
* properties are indented by exactly one tab; nested blocks (endpoints,
* spawn info) carry their own `pid` lines and must not be read as the job's. */
export function parseLaunchctlPid(stdout: string): number | null {
for (const line of stdout.split(/\r?\n/)) {
const match = /^\tpid = (\d+)\s*$/.exec(line);
if (!match) continue;
const pid = Number(match[1]);
return Number.isSafeInteger(pid) && pid > 1 ? pid : null;
}
return null;
}
function state(
name: RuntimeServiceStateName,
reason: RuntimeServiceStateReason,
jobPid: number | null = null,
): RuntimeServiceState {
return { state: name, reason, jobPid };
}
async function canonical(target: string): Promise<string> {
const resolved = path.resolve(target);
return realpath(resolved).catch(() => resolved);
}
function unescapeXml(value: string): string {
return value
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&quot;", '"')
.replaceAll("&apos;", "'")
.replaceAll("&amp;", "&");
}
async function runProbe(
command: readonly string[],
timeoutMs: number,
): Promise<{ code: number | null; stdout: string }> {
const [program, ...args] = command;
if (!program) throw new Error("runtime_service_inspection_failed");
return new Promise((resolve, reject) => {
const child = spawn(program, args, {
shell: false,
windowsHide: true,
stdio: ["ignore", "pipe", "ignore"],
});
const timer = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error("runtime_service_inspection_timeout"));
}, timeoutMs);
const chunks: Buffer[] = [];
let size = 0;
child.stdout.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size > 256 * 1024) {
child.kill();
return;
}
chunks.push(chunk);
});
child.once("error", () => {
clearTimeout(timer);
reject(new Error("runtime_service_inspection_failed"));
});
child.once("close", (code) => {
clearTimeout(timer);
if (size > 256 * 1024) {
reject(new Error("runtime_service_inspection_failed"));
return;
}
resolve({ code, stdout: Buffer.concat(chunks).toString("utf8") });
});
});
}
+346
View File
@@ -0,0 +1,346 @@
# -*- coding: utf-8 -*-
"""
Intention(L7)读侧判重开关(issue #241)。
覆盖:
1. 默认开启:行为不变,复本只返回最早一条,其余异步删库;
2. 关闭:不隐藏、不删除,两条意图都返回;
3. 阈值生效:cosine 低于阈值不判重,高于阈值判重;
4. reader 按 client 的 MemoryConfig.recall 读取开关/阈值,而非调用时的 env;
5. MemoryConfig 默认值与 env 回退;
6. 写入侧批内判重同样受开关控制;阈值不影响写入侧;
7. 关闭时不为判重补拉 embedding;配置阈值非法时不影响召回。
"""
from __future__ import annotations
import uuid
from datetime import datetime
import pytest
from hy_memory.config import MemoryConfig
from hy_memory.pipelines.base import ReadRequest
from hy_memory.pipelines._retrieval.intention import recall_intentions
from tests.test_intention_dedup import (
_FakeVectorStore,
_make_intention_node,
_run,
)
_MA = datetime(2025, 8, 6)
_VU = datetime(2025, 8, 31, 23, 59)
_NOW = datetime(2025, 8, 6, 12, 0)
def _unit(vec):
n = sum(x * x for x in vec) ** 0.5 or 1.0
return [x / n for x in vec]
def _seed_project_pair(vs, *, emb_a, emb_b):
"""A / B 两个项目的近似意图:同 user/agent/同天/同截止日。A 更早写入。"""
a = _make_intention_node(
"A 项目每周一触发发布前校验流程", memory_at=_MA, valid_until=_VU,
gmt_created=datetime(2025, 8, 1), embedding=emb_a,
)
b = _make_intention_node(
"B 项目每周一触发发布前校验流程", memory_at=_MA, valid_until=_VU,
gmt_created=datetime(2025, 8, 2), embedding=emb_b,
)
_run(vs.upsert(a))
_run(vs.upsert(b))
return a, b
# ============================================================================
# recall_intentions 参数
# ============================================================================
def test_default_on_keeps_earliest_and_deletes_duplicate():
vs = _FakeVectorStore()
emb = _unit([1.0, 0.0])
a, b = _seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
stats = {}
result = _run(recall_intentions(
vs, emb, user_ids=["u1"], limit=10, now=_NOW, stats_out=stats,
))
assert [h["node_id"] for h in result] == [a.node_id]
assert vs.delete_calls == [b.node_id]
assert stats["dedup_enabled"] is True
assert stats["dedup_dropped"] == 1
def test_off_returns_both_and_deletes_nothing():
vs = _FakeVectorStore()
emb = _unit([1.0, 0.0])
a, b = _seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
stats = {}
result = _run(recall_intentions(
vs, emb, user_ids=["u1"], limit=10, now=_NOW, stats_out=stats,
dedup_enabled=False,
))
assert {h["node_id"] for h in result} == {a.node_id, b.node_id}
assert vs.delete_calls == []
assert stats["dedup_enabled"] is False
assert stats["dedup_dropped"] == 0
def test_threshold_respected():
# cosine(a, b) = 0.9
emb_a = _unit([1.0, 0.0])
emb_b = _unit([0.9, (1 - 0.81) ** 0.5])
# 默认 0.95 → 0.9 不算复本
vs = _FakeVectorStore()
_seed_project_pair(vs, emb_a=emb_a, emb_b=emb_b)
result = _run(recall_intentions(
vs, emb_a, user_ids=["u1"], limit=10, now=_NOW, dedup_threshold=0.95,
))
assert len(result) == 2
assert vs.delete_calls == []
# 调低到 0.85 → 判重,删较晚的 B
vs = _FakeVectorStore()
a, b = _seed_project_pair(vs, emb_a=emb_a, emb_b=emb_b)
stats = {}
result = _run(recall_intentions(
vs, emb_a, user_ids=["u1"], limit=10, now=_NOW,
dedup_threshold=0.85, stats_out=stats,
))
assert [h["node_id"] for h in result] == [a.node_id]
assert vs.delete_calls == [b.node_id]
assert stats["dedup_threshold"] == 0.85
def test_explicit_threshold_wins_over_env(monkeypatch):
monkeypatch.setenv("MEMORY_DEDUP_THRESHOLD", "0.5")
emb_a = _unit([1.0, 0.0])
emb_b = _unit([0.9, (1 - 0.81) ** 0.5])
vs = _FakeVectorStore()
_seed_project_pair(vs, emb_a=emb_a, emb_b=emb_b)
result = _run(recall_intentions(
vs, emb_a, user_ids=["u1"], limit=10, now=_NOW, dedup_threshold=0.95,
))
assert len(result) == 2
assert vs.delete_calls == []
def test_off_skips_embedding_backfill():
vs = _FakeVectorStore()
emb = _unit([1.0, 0.0])
_seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
backfill_calls = []
async def _get_embeddings(ids):
backfill_calls.append(ids)
return {}
vs.get_embeddings = _get_embeddings
for n in vs._nodes.values():
n.embedding = None
result = _run(recall_intentions(
vs, emb, user_ids=["u1"], limit=10, now=_NOW, dedup_enabled=False,
))
assert len(result) == 2
assert backfill_calls == []
def test_invalid_threshold_degrades_to_env_default(monkeypatch):
monkeypatch.delenv("MEMORY_DEDUP_THRESHOLD", raising=False)
vs = _FakeVectorStore()
emb = _unit([1.0, 0.0])
a, b = _seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
stats = {}
result = _run(recall_intentions(
vs, emb, user_ids=["u1"], limit=10, now=_NOW, stats_out=stats,
dedup_threshold="not-a-number",
))
assert [h["node_id"] for h in result] == [a.node_id]
assert stats["dedup_threshold"] == 0.95
# ============================================================================
# 写入侧批内判重
# ============================================================================
class _SameVectorEmbed:
"""所有文本同一向量:cosine=1,开启判重时必判为复本。"""
async def embed_batch(self, texts):
return [_unit([1.0, 0.0]) for _ in texts]
async def embed_queued(self, text):
return _unit([1.0, 0.0])
def _store_batch(config_recall):
from hy_memory.pipelines.base import WriteRequest
from hy_memory.pipelines.writer import MemoryWriter
cfg = MemoryConfig.from_dict({"recall": config_recall})
vs = _FakeVectorStore()
writer = MemoryWriter(cfg, embed_service=_SameVectorEmbed(), vector_store=vs)
req = WriteRequest(
content="", messages=[], user_id="u1", agent_id="a1",
session_id="s1", request_id=uuid.uuid4().hex,
)
intentions = [
{"content": "A 项目每周一触发发布前校验流程", "tags": [], "valid_until": None},
{"content": "B 项目每周一触发发布前校验流程", "tags": [], "valid_until": None},
]
return _run(writer._store_intentions(intentions, req, vs, req.request_id)), vs
def test_write_batch_dedup_default_on_unchanged():
ids, vs = _store_batch({})
assert len(ids) == 1
assert [n.content for n in vs._nodes.values()] == [
"A 项目每周一触发发布前校验流程",
]
def test_write_batch_dedup_off_stores_both():
ids, vs = _store_batch({"intention_dedup_enabled": False})
assert len(ids) == 2
assert len(vs._nodes) == 2
def test_read_threshold_does_not_change_write_batch_dedup(monkeypatch):
"""intention_dedup_threshold 只管读侧;写入侧仍取 MEMORY_DEDUP_THRESHOLD。"""
monkeypatch.delenv("MEMORY_DEDUP_THRESHOLD", raising=False)
ids, _ = _store_batch({"intention_dedup_threshold": 1.5})
assert len(ids) == 1
# ============================================================================
# MemoryConfig
# ============================================================================
def test_config_defaults(monkeypatch):
for k in (
"MEMORY_INTENTION_DEDUP_ENABLED",
"MEMORY_INTENTION_DEDUP_THRESHOLD",
"MEMORY_DEDUP_THRESHOLD",
):
monkeypatch.delenv(k, raising=False)
cfg = MemoryConfig()
assert cfg.recall.intention_dedup_enabled is True
assert cfg.recall.intention_dedup_threshold == 0.95
def test_config_env_fallbacks(monkeypatch):
monkeypatch.setenv("MEMORY_INTENTION_DEDUP_ENABLED", "false")
monkeypatch.setenv("MEMORY_DEDUP_THRESHOLD", "0.9")
monkeypatch.delenv("MEMORY_INTENTION_DEDUP_THRESHOLD", raising=False)
cfg = MemoryConfig()
assert cfg.recall.intention_dedup_enabled is False
# 未设专用阈值 → 回退共享 MEMORY_DEDUP_THRESHOLD(兼容旧部署)
assert cfg.recall.intention_dedup_threshold == 0.9
monkeypatch.setenv("MEMORY_INTENTION_DEDUP_THRESHOLD", "0.8")
assert MemoryConfig().recall.intention_dedup_threshold == 0.8
def test_config_from_dict_overrides_env(monkeypatch):
monkeypatch.setenv("MEMORY_INTENTION_DEDUP_ENABLED", "true")
cfg = MemoryConfig.from_dict({"recall": {
"intention_dedup_enabled": False,
"intention_dedup_threshold": 0.99,
}})
assert cfg.recall.intention_dedup_enabled is False
assert cfg.recall.intention_dedup_threshold == 0.99
assert cfg.to_dict()["recall"]["intention_dedup_enabled"] is False
# ============================================================================
# reader:按 client 配置,而不是调用时的 env
# ============================================================================
def _reader_for(enabled: bool, vs):
from hy_memory.pipelines.reader_hybrid import HybridReadPipeline
cfg = MemoryConfig.from_dict({
"vector_store": {
"provider": "chroma",
"collection_name": f"t_intent_sw_{uuid.uuid4().hex[:8]}",
"embedding_dims": 2,
},
"recall": {"intention_dedup_enabled": enabled},
})
reader = HybridReadPipeline(cfg, vector_store=vs)
reader._vector_store = vs
reader._vector_store_initialized = True
return reader
async def _no_keyword_hits(**kw):
return []
def _read_project_pair(enabled: bool):
"""经 reader 完整读路径召回 A/B 两条近似意图;返回 (返回的意图 id, 删库调用, a, b)。"""
vs = _FakeVectorStore()
vs.keyword_search = _no_keyword_hits
emb = _unit([1.0, 0.0])
a, b = _seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
# 读路径用真实 now:让两条都未过期且同一天
today = datetime.now()
for n in (a, b):
n.memory_at = today
n.valid_until = None
reader = _reader_for(enabled, vs)
_run(reader.initialize())
resp = _run(reader.read(ReadRequest(
query="发布前校验", query_embedding=emb,
user_ids=["u1"], agent_ids=["a1"], limit=10, intention_limit=5,
)))
ids = {
m["memory_id"] for m in resp.memories
if m.get("layer") == "l7_intention"
}
return ids, list(vs.delete_calls), a, b
@pytest.mark.parametrize("env_value", ["true", "false"])
def test_reader_uses_per_client_config_not_env(monkeypatch, env_value):
"""调用时的 env 与 client 配置无关:两个 client 一开一关,各自生效。"""
monkeypatch.setenv("MEMORY_INTENTION_DEDUP_ENABLED", env_value)
monkeypatch.setenv("MEMORY_DEDUP_THRESHOLD", "0.0")
ids_on, deleted_on, a, b = _read_project_pair(True)
assert ids_on == {a.node_id}
assert deleted_on == [b.node_id]
ids_off, deleted_off, a, b = _read_project_pair(False)
assert ids_off == {a.node_id, b.node_id}
assert deleted_off == []
def test_reader_passes_client_threshold(monkeypatch):
monkeypatch.setenv("MEMORY_DEDUP_THRESHOLD", "0.1")
seen = []
async def _spy(*args, **kwargs):
seen.append(kwargs)
return []
monkeypatch.setattr("hy_memory.pipelines.reader_base.recall_intentions", _spy)
vs = _FakeVectorStore()
vs.keyword_search = _no_keyword_hits
reader = _reader_for(True, vs)
reader.config.recall.intention_dedup_threshold = 0.97
_run(reader.initialize())
_run(reader.read(ReadRequest(
query="发布前校验", query_embedding=_unit([1.0, 0.0]),
user_ids=["u1"], agent_ids=["a1"], limit=10, intention_limit=5,
)))
assert seen and seen[0]["dedup_enabled"] is True
assert seen[0]["dedup_threshold"] == 0.97