Merge pull request #221 from Tencent-Hunyuan/fix/linux-lifecycle-queue-check

fix(memhub): Linux lifecycle checks the write queue, not recall without a model
This commit is contained in:
feitianxiang authored and GitHub committed 2026-10-28 20:17:45 +08:00
commit 5f78d67363
3 files changed
+107 -25

No files matched your search

@@ -0,0 +1,66 @@
# TODO: recall imported conversations before a model is configured
Status: open. Found 2026-10-28 by the Linux lifecycle run on build 35.
## Problem
A new user who has imported conversations but not configured a generation
model gets nothing from `MemHub_recall` or `memhub recall`.
`recall_with_fallback` (`apps/core/src/one_memory/recall_fallback.py`) is
meant to cover this case: without a model it searches the SDK raw layer
(`l1_raw`), the imported conversations themselves. But under SDK authority
nothing writes that layer until a model is active:
- File import and live capture only stage conversations into
`memory_write_jobs` (assembling, then pending).
- The durable write drain claims a job only when
`_sedimentation_claim_enabled` holds, which requires an active model
profile (`api.py`).
- The SDK writes `l1_raw` only inside `client.add`, which the drain calls.
So until a model is configured, the raw layer is empty and the fallback has
nothing to search. Before the SDK cutover, imported Evidence lived in
MemHub's own database and was searchable without a model.
The Linux lifecycle verification used to assert "import, then recall finds
it" on a machine with no model. It now asserts that the import reached the
write queue instead (`scripts/platform/linux/verify-linux-lifecycle.mjs`,
`waitForQueued`). Restore the recall assertion once this is fixed.
## Direction
Write imported conversations to the SDK raw layer without a generation
model, and leave extraction for when a model is configured.
- The raw layer needs embeddings only. The bundled local ONNX model
(`builtin_retrieval_snapshot`) can produce them, so no network model is
needed.
- SDK: a public raw-only write, for example `add(..., infer=False)` or
`add_raw(messages, ...)`, that stores `l1_raw` with source, session,
workspace and `memory_at`, and returns its raw ids. It must be idempotent
by `request_id`, like `add`.
- MemHub: split the claim gate. Without a model, the drain may claim a
pending job for a raw-only write. Once a model is active, the same job
still needs extraction, and that write must not store `l1_raw` again.
Either the job records that its raw part is done, or extraction runs
through `add_extracted` / a mode that skips the raw store.
- The source sync switch and grant still apply, exactly as for extraction.
## Open questions
- Should raw-only ingestion run at all when the user has turned
sedimentation off, or does that switch mean "store nothing"?
- Storage cost: raw ingestion of a full history import without a model
embeds every turn up front. Check the time and disk cost on a large
history.
- When the user configures a model later, extract from the stored raw rows,
or re-read the jobs' frozen inputs? The second keeps one write path.
## Done when
- On a fresh install with no model, `memhub recall "<phrase from an
imported conversation>"` returns that conversation with `mode: "raw"`.
- Configuring a model afterwards extracts memories from the same
conversations without duplicating their raw rows.
- `verify-linux-lifecycle.mjs` asserts recall again.
@@ -66,7 +66,6 @@ describe("Linux runtime scripts", () => {
'"stop"',
'"init"',
'"scan"',
'"recall"',
'"uninstall"',
]) {
assert.match(script, new RegExp(command));
@@ -88,9 +87,11 @@ describe("Linux runtime scripts", () => {
script,
/Exit 1 is a valid transient receipt[\s\S]{0,300}\[0, 1, 3\]/,
);
assert.match(script, /append_recalled/);
assert.match(script, /initial_queued/);
assert.match(script, /append_queued/);
assert.match(script, /FROM memory_write_jobs/);
assert.match(script, /idempotent_upgrade_instance_id_changed/);
assert.match(script, /post_upgrade_recalled/);
assert.match(script, /post_upgrade_queued/);
assert.match(script, /uninstall_preserved_data/);
assert.match(script, /is-enabled/);
assert.match(script, /is-active/);
@@ -10,6 +10,7 @@ import {
} from "node:fs";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
function fail(message, receipt) {
if (receipt) process.stdout.write(`${JSON.stringify(receipt, null, 2)}\n`);
@@ -146,18 +147,35 @@ function doctorCheck(document, id) {
return check;
}
async function waitForRecall(prefix, phrase) {
/**
* Whether an imported phrase reached Core's durable write queue.
*
* Recall cannot prove an import here: this runner has no generation model,
* and with SDK authority nothing reaches the SDK (not even its raw layer)
* until a model is active. What the lifecycle can prove is that the import
* was read and queued for the SDK, durably, in the Runtime's own database.
*/
async function waitForQueued(phrase) {
const deadline = Date.now() + 90_000;
let attempt = 0;
const database = path.join(dataDir, "one-memory.sqlite3");
while (Date.now() < deadline) {
attempt += 1;
const result = run(
`${prefix}-${attempt}`,
["recall", phrase, "--json"],
[0, 1, 3],
15_000,
);
if (result.stdout.includes(phrase)) return true;
if (existsSync(database)) {
let connection;
try {
connection = new DatabaseSync(database, { readOnly: true });
const row = connection
.prepare(
"SELECT COUNT(*) AS count FROM memory_write_jobs " +
"WHERE instr(payload_json, ?) > 0",
)
.get(phrase);
if (Number(row?.count) > 0) return true;
} catch {
// The queue table appears once Core has migrated its schema.
} finally {
connection?.close();
}
}
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
return false;
@@ -320,12 +338,11 @@ try {
"--import",
"--json",
]);
observations.initial_recalled = await waitForRecall(
"recall-initial",
observations.initial_queued = await waitForQueued(
"Linux lifecycle initial phrase",
);
if (!observations.initial_recalled)
throw new Error("initial import was not recalled");
if (!observations.initial_queued)
throw new Error("initial import was not queued for the SDK");
status.import = "passed";
writeFileSync(
@@ -338,12 +355,11 @@ try {
[0],
30_000,
);
observations.append_recalled = await waitForRecall(
"recall-appended",
observations.append_queued = await waitForQueued(
"Linux lifecycle appended phrase",
);
if (!observations.append_recalled)
throw new Error("appended turn was not recalled");
if (!observations.append_queued)
throw new Error("appended turn was not queued for the SDK");
status.append_sync = "passed";
const beforeUpgrade = await waitForRunningStatus("status-before-upgrade");
@@ -378,12 +394,11 @@ try {
if (!observations.idempotent_upgrade_instance_id_changed) {
throw new Error("idempotent upgrade did not restart the Runtime");
}
observations.post_upgrade_recalled = await waitForRecall(
"recall-after-upgrade",
observations.post_upgrade_queued = await waitForQueued(
"Linux lifecycle appended phrase",
);
if (!observations.post_upgrade_recalled) {
throw new Error("idempotent upgrade lost previously recalled data");
if (!observations.post_upgrade_queued) {
throw new Error("idempotent upgrade lost previously queued data");
}
status.upgrade_idempotent = "passed";