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:
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";
|
||||
|
||||
|
||||
Reference in new issue
Block a user