From c461f4e6ca36f24cd7a9a2561d73ce2160baf583 Mon Sep 17 00:00:00 2001 From: Codex Local Date: Wed, 25 Mar 2026 07:02:42 +0100 Subject: [PATCH] fix(personas): make local migration loaders retryable --- apps/api/src/create-repos.test.ts | 26 ++++++++ apps/api/src/create-repos.ts | 62 ++++++++++--------- ops/v2/personas-runtime-20260325/PLAN.md | 4 +- ops/v2/personas-runtime-20260325/STATUS.md | 2 +- ops/v2/personas-runtime-20260325/TODO.md | 3 +- .../personas-runtime-20260325/pipeline.json | 3 +- ops/v2/personas-runtime-20260325/state.json | 8 ++- 7 files changed, 74 insertions(+), 34 deletions(-) diff --git a/apps/api/src/create-repos.test.ts b/apps/api/src/create-repos.test.ts index 8784c38..725678a 100644 --- a/apps/api/src/create-repos.test.ts +++ b/apps/api/src/create-repos.test.ts @@ -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(); diff --git a/apps/api/src/create-repos.ts b/apps/api/src/create-repos.ts index dc1416e..b051bd1 100644 --- a/apps/api/src/create-repos.ts +++ b/apps/api/src/create-repos.ts @@ -78,6 +78,28 @@ async function writeJson(filePath: string, data: unknown): Promise { await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8"); } +function createRetryableLoader(load: () => Promise): () => Promise { + let loaded = false; + let loadPromise: Promise | null = null; + + return async function ensureLoaded(): Promise { + 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(); - let loaded = false; - - async function ensureLoaded(): Promise { - if (loaded) return; - loaded = true; - + const ensureLoaded = createRetryableLoader(async () => { + personas.clear(); const byPersonaFiles = await readJsonFiles(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 { @@ -288,12 +306,8 @@ export function createLocalNodeRunRepo() { export function createLocalPersonaSourceRepo() { const files = localStoreFiles(); const sources = new Map(); - let loaded = false; - - async function ensureLoaded(): Promise { - if (loaded) return; - loaded = true; - + const ensureLoaded = createRetryableLoader(async () => { + sources.clear(); const byPersonaFiles = await readJsonFiles(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 { @@ -325,12 +339,8 @@ export function createLocalPersonaSourceRepo() { export function createLocalPersonaFeedbackRepo() { const files = localStoreFiles(); const feedback = new Map(); - let loaded = false; - - async function ensureLoaded(): Promise { - if (loaded) return; - loaded = true; - + const ensureLoaded = createRetryableLoader(async () => { + feedback.clear(); const byPersonaFiles = await readJsonFiles(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 { @@ -374,12 +384,8 @@ export function createLocalPersonaFeedbackRepo() { export function createLocalPersonaProposalRepo() { const files = localStoreFiles(); const proposals = new Map(); - let loaded = false; - - async function ensureLoaded(): Promise { - if (loaded) return; - loaded = true; - + const ensureLoaded = createRetryableLoader(async () => { + proposals.clear(); const byPersonaFiles = await readJsonFiles(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 { diff --git a/ops/v2/personas-runtime-20260325/PLAN.md b/ops/v2/personas-runtime-20260325/PLAN.md index 9ef6100..877f29d 100644 --- a/ops/v2/personas-runtime-20260325/PLAN.md +++ b/ops/v2/personas-runtime-20260325/PLAN.md @@ -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 diff --git a/ops/v2/personas-runtime-20260325/STATUS.md b/ops/v2/personas-runtime-20260325/STATUS.md index 50a264e..ea68627 100644 --- a/ops/v2/personas-runtime-20260325/STATUS.md +++ b/ops/v2/personas-runtime-20260325/STATUS.md @@ -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 diff --git a/ops/v2/personas-runtime-20260325/TODO.md b/ops/v2/personas-runtime-20260325/TODO.md index 625ef3c..c961b6a 100644 --- a/ops/v2/personas-runtime-20260325/TODO.md +++ b/ops/v2/personas-runtime-20260325/TODO.md @@ -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 diff --git a/ops/v2/personas-runtime-20260325/pipeline.json b/ops/v2/personas-runtime-20260325/pipeline.json index 5fe73b5..2b5e61c 100644 --- a/ops/v2/personas-runtime-20260325/pipeline.json +++ b/ops/v2/personas-runtime-20260325/pipeline.json @@ -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" }, diff --git a/ops/v2/personas-runtime-20260325/state.json b/ops/v2/personas-runtime-20260325/state.json index e9196a8..3c948f5 100644 --- a/ops/v2/personas-runtime-20260325/state.json +++ b/ops/v2/personas-runtime-20260325/state.json @@ -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,