mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(migrations): converge recoverable legacy state (#104529)
Signed-off-by: sallyom <somalley@redhat.com>
This commit is contained in:
@@ -577,6 +577,30 @@ describe("codex doctor contract", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores metadata-only session rows when proving zero ownership", async () => {
|
||||
const fixture = await createBindingMigrationFixture({
|
||||
name: "orphan-metadata-row",
|
||||
sessionIndex: {
|
||||
"agent:main:metadata-only": {
|
||||
label: "Waiting for first turn",
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
threadId: "thread-orphan",
|
||||
});
|
||||
|
||||
await expect(fixture.migration.migrateLegacyState(fixture.params)).resolves.toEqual({
|
||||
changes: [
|
||||
"Migrated 1 Codex app-server binding sidecar(s) to plugin state and archived the legacy sources",
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
await expect(fs.access(fixture.sidecarPath)).rejects.toThrow();
|
||||
await expect(fs.access(`${fixture.sidecarPath}.migrated`)).resolves.toBeUndefined();
|
||||
|
||||
await fs.rm(fixture.stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("retains a zero-owner sidecar when canonical plugin state is malformed", async () => {
|
||||
const fixture = await createBindingMigrationFixture({
|
||||
name: "orphan-invalid-state",
|
||||
|
||||
@@ -210,6 +210,11 @@ async function readLegacySessionIndex(
|
||||
if (!isRecord(value)) {
|
||||
return { failure: `session index ${storePath} has invalid entries` };
|
||||
}
|
||||
// Metadata-only rows have no transcript identity and therefore cannot own
|
||||
// a binding sidecar. Runtime normalization preserves this shipped shape.
|
||||
if (value.sessionId === undefined) {
|
||||
continue;
|
||||
}
|
||||
const rawSessionId = typeof value.sessionId === "string" ? value.sessionId.trim() : "";
|
||||
const sessionId = normalizedByKey.get(sessionKey)?.sessionId?.trim() ?? "";
|
||||
const sessionFile = value.sessionFile;
|
||||
|
||||
@@ -1200,7 +1200,7 @@ describe("memory-core doctor dreaming migration", () => {
|
||||
expect(retryEntriesAfter).toEqual(retryEntriesBefore);
|
||||
});
|
||||
|
||||
it("leaves the legacy memory sidecar in place when canonical rows conflict", async () => {
|
||||
it("keeps canonical rows and archives a conflicting derived legacy index", async () => {
|
||||
const stateDir = path.join(rootDir, "state");
|
||||
const legacyPath = path.join(stateDir, "memory", "main.sqlite");
|
||||
const agentPath = path.join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite");
|
||||
@@ -1209,22 +1209,21 @@ describe("memory-core doctor dreaming migration", () => {
|
||||
|
||||
const result = await legacyMemoryIndexMigration().migrateLegacyState(migrationParams());
|
||||
|
||||
expect(result.warnings).toEqual([
|
||||
expect.stringContaining(
|
||||
"Skipped Memory Core legacy memory index import for agent main because legacy rows could not be imported: Error: legacy memory files rows conflict",
|
||||
),
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.changes).toEqual([
|
||||
"Resolved Memory Core legacy memory index conflict for agent main by keeping canonical per-agent SQLite rows",
|
||||
expect.stringContaining("Archived Memory Core legacy memory index sidecar"),
|
||||
]);
|
||||
expect(result.changes).toEqual([]);
|
||||
expect(readMemoryRows(agentPath)).toEqual({
|
||||
sources: [{ path: "MEMORY.md", source: "memory", hash: "canonical-file-hash" }],
|
||||
chunks: [{ id: "canonical-chunk", text: "canonical memory remains authoritative" }],
|
||||
cache: [],
|
||||
});
|
||||
await expect(fs.access(legacyPath)).resolves.toBeUndefined();
|
||||
await expect(fs.access(`${legacyPath}.migrated`)).rejects.toThrow();
|
||||
await expect(fs.access(legacyPath)).rejects.toThrow();
|
||||
await expect(fs.access(`${legacyPath}.migrated`)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("copies conflicting custom sidecars to the canonical retry path", async () => {
|
||||
it("archives conflicting custom derived indexes without creating a retry copy", async () => {
|
||||
const stateDir = path.join(rootDir, "state");
|
||||
const legacyPath = path.join(rootDir, "custom-memory", "main.sqlite");
|
||||
const retryPath = path.join(stateDir, "memory", "main.sqlite");
|
||||
@@ -1255,19 +1254,14 @@ describe("memory-core doctor dreaming migration", () => {
|
||||
);
|
||||
|
||||
expect(result.changes).toEqual([
|
||||
`Copied Memory Core legacy memory index sidecar retry path -> ${retryPath}`,
|
||||
"Resolved Memory Core legacy memory index conflict for agent main by keeping canonical per-agent SQLite rows",
|
||||
expect.stringContaining("Archived Memory Core legacy memory index sidecar"),
|
||||
]);
|
||||
expect(result.warnings).toEqual([
|
||||
expect.stringContaining(
|
||||
"Skipped Memory Core legacy memory index import for agent main because legacy rows could not be imported: Error: legacy memory files rows conflict",
|
||||
),
|
||||
]);
|
||||
expect(retryPreview?.preview).toEqual([
|
||||
`- Memory Core legacy memory index: ${retryPath} -> ${agentPath}`,
|
||||
]);
|
||||
await expect(fs.access(legacyPath)).resolves.toBeUndefined();
|
||||
await expect(fs.access(retryPath)).resolves.toBeUndefined();
|
||||
await expect(fs.access(`${legacyPath}.migrated`)).rejects.toThrow();
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(retryPreview).toBeNull();
|
||||
await expect(fs.access(legacyPath)).rejects.toThrow();
|
||||
await expect(fs.access(retryPath)).rejects.toThrow();
|
||||
await expect(fs.access(`${legacyPath}.migrated`)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("copies custom sidecars to the retry path when canonical database setup fails", async () => {
|
||||
|
||||
@@ -106,6 +106,12 @@ type LegacyMemorySidecarImportResult = {
|
||||
|
||||
type MemoryFtsTokenizer = "unicode61" | "trigram";
|
||||
|
||||
class LegacyMemoryRowsConflictError extends Error {
|
||||
constructor(readonly tableName: string) {
|
||||
super(`legacy memory ${tableName} rows conflict with canonical memory index rows`);
|
||||
}
|
||||
}
|
||||
|
||||
function tableExists(db: DatabaseSync, schema: string, tableName: string): boolean {
|
||||
return Boolean(db.prepare(`SELECT 1 FROM ${schema}.sqlite_master WHERE name = ?`).get(tableName));
|
||||
}
|
||||
@@ -208,7 +214,7 @@ function formatLegacyVectorRows(count: number | undefined): string {
|
||||
function assertLegacyRowsCopied(db: DatabaseSync, query: string, tableName: string): void {
|
||||
const row = db.prepare(query).get() as { missing?: unknown } | undefined;
|
||||
if (Number(row?.missing ?? 0) > 0) {
|
||||
throw new Error(`legacy memory ${tableName} rows conflict with canonical memory index rows`);
|
||||
throw new LegacyMemoryRowsConflictError(tableName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -933,6 +939,14 @@ async function migrateLegacyMemorySidecarSource(params: {
|
||||
requireVectorRows: vectorEnabled,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof LegacyMemoryRowsConflictError && err.tableName === "files") {
|
||||
// Memory index rows are derived from canonical memory sources. Keep the
|
||||
// current per-agent index and let normal sync rebuild any stale entries.
|
||||
params.changes.push(
|
||||
`Resolved Memory Core legacy memory index conflict for agent ${params.source.agentId} by keeping canonical per-agent SQLite rows`,
|
||||
);
|
||||
return { archiveReady: true };
|
||||
}
|
||||
await preserveLegacyMemorySidecarRetryPath(params);
|
||||
params.warnings.push(
|
||||
`Skipped Memory Core legacy memory index import for agent ${params.source.agentId} because legacy rows could not be imported: ${String(err)}`,
|
||||
|
||||
Reference in New Issue
Block a user