Skip to content

Commit 05375ba

Browse files
committed
fix(migrations): converge recoverable legacy state
Signed-off-by: sallyom <[email protected]>
1 parent b313685 commit 05375ba

4 files changed

Lines changed: 59 additions & 22 deletions

File tree

extensions/codex/doctor-contract-api.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,30 @@ describe("codex doctor contract", () => {
577577
},
578578
);
579579

580+
it("ignores metadata-only session rows when proving zero ownership", async () => {
581+
const fixture = await createBindingMigrationFixture({
582+
name: "orphan-metadata-row",
583+
sessionIndex: {
584+
"agent:main:metadata-only": {
585+
label: "Waiting for first turn",
586+
updatedAt: 1,
587+
},
588+
},
589+
threadId: "thread-orphan",
590+
});
591+
592+
await expect(fixture.migration.migrateLegacyState(fixture.params)).resolves.toEqual({
593+
changes: [
594+
"Migrated 1 Codex app-server binding sidecar(s) to plugin state and archived the legacy sources",
595+
],
596+
warnings: [],
597+
});
598+
await expect(fs.access(fixture.sidecarPath)).rejects.toThrow();
599+
await expect(fs.access(`${fixture.sidecarPath}.migrated`)).resolves.toBeUndefined();
600+
601+
await fs.rm(fixture.stateDir, { recursive: true, force: true });
602+
});
603+
580604
it("retains a zero-owner sidecar when canonical plugin state is malformed", async () => {
581605
const fixture = await createBindingMigrationFixture({
582606
name: "orphan-invalid-state",

extensions/codex/src/migration/session-binding-sidecars.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,11 @@ async function readLegacySessionIndex(
210210
if (!isRecord(value)) {
211211
return { failure: `session index ${storePath} has invalid entries` };
212212
}
213+
// Metadata-only rows have no transcript identity and therefore cannot own
214+
// a binding sidecar. Runtime normalization preserves this shipped shape.
215+
if (value.sessionId === undefined) {
216+
continue;
217+
}
213218
const rawSessionId = typeof value.sessionId === "string" ? value.sessionId.trim() : "";
214219
const sessionId = normalizedByKey.get(sessionKey)?.sessionId?.trim() ?? "";
215220
const sessionFile = value.sessionFile;

extensions/memory-core/doctor-contract-api.test.ts

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1200,7 +1200,7 @@ describe("memory-core doctor dreaming migration", () => {
12001200
expect(retryEntriesAfter).toEqual(retryEntriesBefore);
12011201
});
12021202

1203-
it("leaves the legacy memory sidecar in place when canonical rows conflict", async () => {
1203+
it("keeps canonical rows and archives a conflicting derived legacy index", async () => {
12041204
const stateDir = path.join(rootDir, "state");
12051205
const legacyPath = path.join(stateDir, "memory", "main.sqlite");
12061206
const agentPath = path.join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite");
@@ -1209,22 +1209,21 @@ describe("memory-core doctor dreaming migration", () => {
12091209

12101210
const result = await legacyMemoryIndexMigration().migrateLegacyState(migrationParams());
12111211

1212-
expect(result.warnings).toEqual([
1213-
expect.stringContaining(
1214-
"Skipped Memory Core legacy memory index import for agent main because legacy rows could not be imported: Error: legacy memory files rows conflict",
1215-
),
1212+
expect(result.warnings).toEqual([]);
1213+
expect(result.changes).toEqual([
1214+
"Resolved Memory Core legacy memory index conflict for agent main by keeping canonical per-agent SQLite rows",
1215+
expect.stringContaining("Archived Memory Core legacy memory index sidecar"),
12161216
]);
1217-
expect(result.changes).toEqual([]);
12181217
expect(readMemoryRows(agentPath)).toEqual({
12191218
sources: [{ path: "MEMORY.md", source: "memory", hash: "canonical-file-hash" }],
12201219
chunks: [{ id: "canonical-chunk", text: "canonical memory remains authoritative" }],
12211220
cache: [],
12221221
});
1223-
await expect(fs.access(legacyPath)).resolves.toBeUndefined();
1224-
await expect(fs.access(`${legacyPath}.migrated`)).rejects.toThrow();
1222+
await expect(fs.access(legacyPath)).rejects.toThrow();
1223+
await expect(fs.access(`${legacyPath}.migrated`)).resolves.toBeUndefined();
12251224
});
12261225

1227-
it("copies conflicting custom sidecars to the canonical retry path", async () => {
1226+
it("archives conflicting custom derived indexes without creating a retry copy", async () => {
12281227
const stateDir = path.join(rootDir, "state");
12291228
const legacyPath = path.join(rootDir, "custom-memory", "main.sqlite");
12301229
const retryPath = path.join(stateDir, "memory", "main.sqlite");
@@ -1255,19 +1254,14 @@ describe("memory-core doctor dreaming migration", () => {
12551254
);
12561255

12571256
expect(result.changes).toEqual([
1258-
`Copied Memory Core legacy memory index sidecar retry path -> ${retryPath}`,
1259-
]);
1260-
expect(result.warnings).toEqual([
1261-
expect.stringContaining(
1262-
"Skipped Memory Core legacy memory index import for agent main because legacy rows could not be imported: Error: legacy memory files rows conflict",
1263-
),
1264-
]);
1265-
expect(retryPreview?.preview).toEqual([
1266-
`- Memory Core legacy memory index: ${retryPath} -> ${agentPath}`,
1257+
"Resolved Memory Core legacy memory index conflict for agent main by keeping canonical per-agent SQLite rows",
1258+
expect.stringContaining("Archived Memory Core legacy memory index sidecar"),
12671259
]);
1268-
await expect(fs.access(legacyPath)).resolves.toBeUndefined();
1269-
await expect(fs.access(retryPath)).resolves.toBeUndefined();
1270-
await expect(fs.access(`${legacyPath}.migrated`)).rejects.toThrow();
1260+
expect(result.warnings).toEqual([]);
1261+
expect(retryPreview).toBeNull();
1262+
await expect(fs.access(legacyPath)).rejects.toThrow();
1263+
await expect(fs.access(retryPath)).rejects.toThrow();
1264+
await expect(fs.access(`${legacyPath}.migrated`)).resolves.toBeUndefined();
12711265
});
12721266

12731267
it("copies custom sidecars to the retry path when canonical database setup fails", async () => {

extensions/memory-core/doctor-contract-api.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@ type LegacyMemorySidecarImportResult = {
106106

107107
type MemoryFtsTokenizer = "unicode61" | "trigram";
108108

109+
class LegacyMemoryRowsConflictError extends Error {
110+
constructor(readonly tableName: string) {
111+
super(`legacy memory ${tableName} rows conflict with canonical memory index rows`);
112+
}
113+
}
114+
109115
function tableExists(db: DatabaseSync, schema: string, tableName: string): boolean {
110116
return Boolean(db.prepare(`SELECT 1 FROM ${schema}.sqlite_master WHERE name = ?`).get(tableName));
111117
}
@@ -208,7 +214,7 @@ function formatLegacyVectorRows(count: number | undefined): string {
208214
function assertLegacyRowsCopied(db: DatabaseSync, query: string, tableName: string): void {
209215
const row = db.prepare(query).get() as { missing?: unknown } | undefined;
210216
if (Number(row?.missing ?? 0) > 0) {
211-
throw new Error(`legacy memory ${tableName} rows conflict with canonical memory index rows`);
217+
throw new LegacyMemoryRowsConflictError(tableName);
212218
}
213219
}
214220

@@ -933,6 +939,14 @@ async function migrateLegacyMemorySidecarSource(params: {
933939
requireVectorRows: vectorEnabled,
934940
});
935941
} catch (err) {
942+
if (err instanceof LegacyMemoryRowsConflictError && err.tableName === "files") {
943+
// Memory index rows are derived from canonical memory sources. Keep the
944+
// current per-agent index and let normal sync rebuild any stale entries.
945+
params.changes.push(
946+
`Resolved Memory Core legacy memory index conflict for agent ${params.source.agentId} by keeping canonical per-agent SQLite rows`,
947+
);
948+
return { archiveReady: true };
949+
}
936950
await preserveLegacyMemorySidecarRetryPath(params);
937951
params.warnings.push(
938952
`Skipped Memory Core legacy memory index import for agent ${params.source.agentId} because legacy rows could not be imported: ${String(err)}`,

0 commit comments

Comments
 (0)