fix(personas): make local migration loaders retryable

This commit is contained in:
Codex Local
2026-03-25 07:02:42 +01:00
parent 25ad84fc69
commit c461f4e6ca
7 changed files with 74 additions and 34 deletions
+26
View File
@@ -82,6 +82,32 @@ describe("create-repos personas runtime", { concurrency: false }, () => {
});
});
it("retries a blocked legacy persona migration after the filesystem is fixed", async () => {
await withTempLocalDataDir(async (dir) => {
const blocker = path.join(dir, "personas");
const migratedFile = path.join(dir, "personas", "legacy_ada.json");
await writeFile(
path.join(dir, "personas.json"),
`${JSON.stringify([
{ id: "legacy_ada", name: "Legacy Ada", model: "qwen2.5:14b", summary: "legacy summary", editable: true },
], null, 2)}\n`,
"utf8",
);
await writeFile(blocker, "blocked", "utf8");
const repo = createLocalPersonaRepo();
await assert.rejects(repo.findById("legacy_ada"));
await rm(blocker, { force: true });
const row = await repo.findById("legacy_ada");
assert.ok(row);
assert.equal(row.name, "Legacy Ada");
const persisted = JSON.parse(await readFile(migratedFile, "utf8"));
assert.equal(persisted.id, "legacy_ada");
});
});
it("returns defensive clones from persona and source repos", async () => {
await withTempLocalDataDir(async () => {
const personaRepo = createLocalPersonaRepo();
+34 -28
View File
@@ -78,6 +78,28 @@ async function writeJson(filePath: string, data: unknown): Promise<void> {
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
}
function createRetryableLoader(load: () => Promise<void>): () => Promise<void> {
let loaded = false;
let loadPromise: Promise<void> | null = null;
return async function ensureLoaded(): Promise<void> {
if (loaded) return;
if (!loadPromise) {
loadPromise = (async () => {
await load();
loaded = true;
})();
}
try {
await loadPromise;
} catch (error) {
loadPromise = null;
throw error;
}
loadPromise = null;
};
}
function clonePersonaRecord(persona: PersonaRecord): PersonaRecord {
return { ...persona };
}
@@ -157,12 +179,8 @@ export function createLocalSessionRepo() {
export function createLocalPersonaRepo() {
const files = localStoreFiles();
const personas = new Map<string, PersonaRecord>();
let loaded = false;
async function ensureLoaded(): Promise<void> {
if (loaded) return;
loaded = true;
const ensureLoaded = createRetryableLoader(async () => {
personas.clear();
const byPersonaFiles = await readJsonFiles<PersonaRecord>(files.personasDir);
for (const persona of byPersonaFiles) {
personas.set(persona.id, { ...persona });
@@ -184,7 +202,7 @@ export function createLocalPersonaRepo() {
personas.set(seed.id, cloned);
await writeJson(personaFilePath(files.personasDir, seed.id), cloned);
}
}
});
return {
async list(): Promise<PersonaRecord[]> {
@@ -288,12 +306,8 @@ export function createLocalNodeRunRepo() {
export function createLocalPersonaSourceRepo() {
const files = localStoreFiles();
const sources = new Map<string, PersonaSourceRecord>();
let loaded = false;
async function ensureLoaded(): Promise<void> {
if (loaded) return;
loaded = true;
const ensureLoaded = createRetryableLoader(async () => {
sources.clear();
const byPersonaFiles = await readJsonFiles<PersonaSourceRecord>(files.personaSourcesDir);
for (const source of byPersonaFiles) {
sources.set(source.personaId, { ...source });
@@ -305,7 +319,7 @@ export function createLocalPersonaSourceRepo() {
sources.set(source.personaId, { ...source });
await writeJson(personaFilePath(files.personaSourcesDir, source.personaId), source);
}
}
});
return {
async findByPersonaId(personaId: string): Promise<PersonaSourceRecord | null> {
@@ -325,12 +339,8 @@ export function createLocalPersonaSourceRepo() {
export function createLocalPersonaFeedbackRepo() {
const files = localStoreFiles();
const feedback = new Map<string, PersonaFeedbackRecord[]>();
let loaded = false;
async function ensureLoaded(): Promise<void> {
if (loaded) return;
loaded = true;
const ensureLoaded = createRetryableLoader(async () => {
feedback.clear();
const byPersonaFiles = await readJsonFiles<PersonaFeedbackRecord[] | PersonaFeedbackRecord>(files.personaFeedbackDir);
for (const payload of byPersonaFiles) {
const records = Array.isArray(payload) ? payload : [payload];
@@ -353,7 +363,7 @@ export function createLocalPersonaFeedbackRepo() {
for (const personaId of dirtyPersonaIds) {
await writeJson(personaFilePath(files.personaFeedbackDir, personaId), feedback.get(personaId) || []);
}
}
});
return {
async listByPersonaId(personaId: string): Promise<PersonaFeedbackRecord[]> {
@@ -374,12 +384,8 @@ export function createLocalPersonaFeedbackRepo() {
export function createLocalPersonaProposalRepo() {
const files = localStoreFiles();
const proposals = new Map<string, PersonaProposalRecord[]>();
let loaded = false;
async function ensureLoaded(): Promise<void> {
if (loaded) return;
loaded = true;
const ensureLoaded = createRetryableLoader(async () => {
proposals.clear();
const byPersonaFiles = await readJsonFiles<PersonaProposalRecord[] | PersonaProposalRecord>(files.personaProposalsDir);
for (const payload of byPersonaFiles) {
const records = Array.isArray(payload) ? payload : [payload];
@@ -402,7 +408,7 @@ export function createLocalPersonaProposalRepo() {
for (const personaId of dirtyPersonaIds) {
await writeJson(personaFilePath(files.personaProposalsDir, personaId), proposals.get(personaId) || []);
}
}
});
return {
async listByPersonaId(personaId: string): Promise<PersonaProposalRecord[]> {
+2 -2
View File
@@ -1,6 +1,6 @@
# PLAN (kxkm-personas-runtime-20260325)
Updated: 2026-03-25T06:00:30Z
Updated: 2026-03-25T06:02:30Z
## lot-201-runtime-hardening [done]
- Description: Durcir le runtime personas local et basculer le store actif en per-file v2-local
@@ -8,7 +8,7 @@ Updated: 2026-03-25T06:00:30Z
- Owner: Personas
- Execution: manual
- Checks: npm run check, npm run test:v2
- Summary: Done: persistence per-file, merge legacy partiel -> per-file, clones defensifs, tests de non-regression, scripts smoke/build alignes.
- Summary: Done: persistence per-file, merge legacy partiel -> per-file, loaders retryables, clones defensifs, tests de non-regression, scripts smoke/build alignes.
## lot-202-memory-schema [pending]
- Description: Formaliser un schema memory persona v2 avec working memory et archival memory
+1 -1
View File
@@ -1,6 +1,6 @@
# EXECUTION STATUS (kxkm-personas-runtime-20260325)
Updated: 2026-03-25T06:00:30Z
Updated: 2026-03-25T06:02:30Z
## lot-201-runtime-hardening
- Status: done
+2 -1
View File
@@ -1,10 +1,11 @@
# TODO (kxkm-personas-runtime-20260325)
Updated: 2026-03-25T06:00:30Z
Updated: 2026-03-25T06:02:30Z
## lot-201-runtime-hardening
- [x] per-file-store (done) | owner: Personas | severity: P1
- [x] partial-legacy-merge (done) | owner: Backend API | severity: P1
- [x] retryable-loader (done) | owner: Backend API | severity: P1
- [x] defensive-snapshots (done) | owner: Backend API | severity: P1
- [x] regression-tests (done) | owner: Backend API | severity: P1
- [x] script-alignment (done) | owner: Ops/TUI | severity: P1
@@ -6,7 +6,7 @@
{
"id": "lot-201-runtime-hardening",
"description": "Durcir le runtime personas local et basculer le store actif en per-file v2-local",
"summary": "Done: persistence per-file, merge legacy partiel -> per-file, clones defensifs, tests de non-regression, scripts smoke/build alignes.",
"summary": "Done: persistence per-file, merge legacy partiel -> per-file, loaders retryables, clones defensifs, tests de non-regression, scripts smoke/build alignes.",
"status": "done",
"owner": "Personas",
"execution": "manual",
@@ -15,6 +15,7 @@
"tasks": [
{ "id": "per-file-store", "query": "Migrer le store runtime personas vers data/v2-local fichier par persona", "status": "done", "owner": "Personas", "severity": "P1" },
{ "id": "partial-legacy-merge", "query": "Fusionner les restes legacy aggregates dans un store per-file partiellement migre", "status": "done", "owner": "Backend API", "severity": "P1" },
{ "id": "retryable-loader", "query": "Rendre les loaders de migration retryables apres une erreur filesystem transitoire", "status": "done", "owner": "Backend API", "severity": "P1" },
{ "id": "defensive-snapshots", "query": "Supprimer les fuites de references mutables dans les repos in-memory personas", "status": "done", "owner": "Backend API", "severity": "P1" },
{ "id": "regression-tests", "query": "Ajouter des tests pour migration legacy et immutabilite des snapshots", "status": "done", "owner": "Backend API", "severity": "P1" },
{ "id": "script-alignment", "query": "Aligner smoke/build/log purge sur data/v2-local et compat explicite", "status": "done", "owner": "Ops/TUI", "severity": "P1" },
+7 -1
View File
@@ -1,6 +1,6 @@
{
"project": "kxkm-personas-runtime-20260325",
"updated_at": "2026-03-25T06:00:30Z",
"updated_at": "2026-03-25T06:02:30Z",
"batches": {
"lot-201-runtime-hardening": {
"status": "done",
@@ -19,6 +19,12 @@
"output": "apps/api/src/create-repos.ts",
"last_error": ""
},
"retryable-loader": {
"status": "done",
"attempts": 1,
"output": "apps/api/src/create-repos.ts",
"last_error": ""
},
"defensive-snapshots": {
"status": "done",
"attempts": 0,