fix(migrations): converge recoverable legacy state (#104529)

Signed-off-by: sallyom <somalley@redhat.com>
This commit is contained in:
Sally O'Malley
2026-07-11 11:19:12 -07:00
committed by GitHub
parent 43c40a3b3e
commit cdb58cafb5
4 changed files with 59 additions and 22 deletions
@@ -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 () => {
+15 -1
View File
@@ -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)}`,